Skip to content

Commit d7da524

Browse files
committed
Add check dependencies script.
1 parent 123d05e commit d7da524

2 files changed

Lines changed: 173 additions & 1 deletion

File tree

‎package.json‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
"workspaces": [
99
"packages/*"
1010
],
11+
"dependencyConsistency": {
12+
"exceptions": {
13+
"typescript": "The repository uses TypeScript 7 for tsc; the VS Code extension needs TypeScript 6's JavaScript compiler API."
14+
}
15+
},
1116
"license": "MIT",
1217
"repository": {
1318
"type": "git",
@@ -43,6 +48,7 @@
4348
"lint": "yarn workspaces foreach --no-private --all --verbose --topological -- run lint",
4449
"lint:ci": "yarn workspaces foreach --no-private --all --verbose --topological -- exec biome check ./src --formatter-enabled=false",
4550
"check-eol": "yarn tsx scripts/check-eol.ts",
51+
"check-dependencies": "yarn tsx scripts/check-dependencies.ts",
4652
"bump-version": "yarn tsx --no-warnings scripts/bump-version.ts",
4753
"bundle": "yarn tsx --no-warnings scripts/bundle.ts",
4854
"cleanup": "yarn tsx --no-warnings scripts/cleanup.ts",
@@ -53,6 +59,6 @@
5359
"pack-vscode-ext": "yarn --cwd packages/ts-ssg-vscode build && yarn --cwd packages/ts-ssg-vscode package",
5460
"longest": "yarn tsx ./scripts/longest-functions.ts",
5561
"format": "biome format --write",
56-
"pre-commit": "yarn check-eol && yarn regor-components && yarn embed && yarn format && yarn lint && yarn build && yarn test run"
62+
"pre-commit": "yarn check-dependencies && yarn check-eol && yarn regor-components && yarn embed && yarn format && yarn lint && yarn build && yarn test run"
5763
}
5864
}

‎scripts/check-dependencies.ts‎

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { existsSync, globSync, readFileSync, realpathSync } from 'node:fs'
2+
import { createRequire } from 'node:module'
3+
import path from 'node:path'
4+
5+
interface PackageJson {
6+
name: string
7+
version?: string
8+
workspaces?: string[]
9+
dependencies?: Record<string, string>
10+
devDependencies?: Record<string, string>
11+
peerDependencies?: Record<string, string>
12+
optionalDependencies?: Record<string, string>
13+
peerDependenciesMeta?: Record<string, { optional?: boolean }>
14+
dependencyConsistency?: { exceptions?: Record<string, string> }
15+
}
16+
17+
interface Declaration {
18+
manifestPath: string
19+
workspace: string
20+
section: string
21+
range: string
22+
optional: boolean
23+
}
24+
25+
const root = process.cwd()
26+
const readManifest = (file: string): PackageJson =>
27+
JSON.parse(readFileSync(file, 'utf8'))
28+
const rootManifestPath = path.join(root, 'package.json')
29+
const rootManifest = readManifest(rootManifestPath)
30+
const exceptions = rootManifest.dependencyConsistency?.exceptions ?? {}
31+
const workspaceManifests = globSync(
32+
(rootManifest.workspaces ?? []).map(
33+
(workspace) => `${workspace}/package.json`,
34+
),
35+
{ cwd: root },
36+
).sort()
37+
const sections = [
38+
'dependencies',
39+
'devDependencies',
40+
'peerDependencies',
41+
'optionalDependencies',
42+
] as const
43+
const dependencies = new Map<string, Declaration[]>()
44+
45+
for (const manifestPath of new Set([
46+
rootManifestPath,
47+
...workspaceManifests.map((file) => path.resolve(root, file)),
48+
])) {
49+
const manifest = readManifest(manifestPath)
50+
for (const section of sections) {
51+
for (const [dependency, range] of Object.entries(manifest[section] ?? {})) {
52+
const declarations = dependencies.get(dependency) ?? []
53+
declarations.push({
54+
manifestPath,
55+
workspace: manifest.name,
56+
section,
57+
range,
58+
optional:
59+
section === 'optionalDependencies' ||
60+
(section === 'peerDependencies' &&
61+
manifest.peerDependenciesMeta?.[dependency]?.optional === true),
62+
})
63+
dependencies.set(dependency, declarations)
64+
}
65+
}
66+
}
67+
68+
function resolveInstalledPackage(dependency: string, manifestPath: string) {
69+
const searchPaths =
70+
createRequire(manifestPath).resolve.paths(dependency) ?? []
71+
for (const searchPath of searchPaths) {
72+
// Inspect the package directory, including packages without a runtime entry
73+
// or an exported package.json (types, binaries, and import-only packages).
74+
const installedManifestPath = path.join(
75+
searchPath,
76+
dependency,
77+
'package.json',
78+
)
79+
if (!existsSync(installedManifestPath)) continue
80+
return {
81+
version: readManifest(installedManifestPath).version,
82+
directory: realpathSync(path.dirname(installedManifestPath)),
83+
}
84+
}
85+
return undefined
86+
}
87+
88+
const failures: string[] = []
89+
const skipped: string[] = []
90+
let checked = 0
91+
92+
for (const [dependency, declarations] of [...dependencies].sort(([a], [b]) =>
93+
a.localeCompare(b),
94+
)) {
95+
if (declarations.length < 2) continue
96+
const exception = exceptions[dependency]
97+
if (exception?.trim()) {
98+
skipped.push(`${dependency}: ${exception}`)
99+
continue
100+
}
101+
checked += 1
102+
const reasons: string[] = []
103+
const reports: string[] = []
104+
const installedDirectories = new Set<string>()
105+
if (new Set(declarations.map(({ range }) => range)).size > 1) {
106+
reasons.push('Dependency declarations do not match.')
107+
}
108+
for (const declaration of declarations) {
109+
const report = [
110+
` ${declaration.workspace} (${path.relative(root, declaration.manifestPath)})`,
111+
` ${declaration.section}: ${declaration.range}`,
112+
]
113+
try {
114+
const installed = resolveInstalledPackage(
115+
dependency,
116+
declaration.manifestPath,
117+
)
118+
if (installed) {
119+
installedDirectories.add(installed.directory)
120+
report.push(
121+
` installed: ${installed.version}`,
122+
` directory: ${installed.directory}`,
123+
)
124+
} else {
125+
report.push(
126+
declaration.optional
127+
? ' installed: absent (optional)'
128+
: ' installed: missing',
129+
)
130+
if (!declaration.optional) {
131+
reasons.push(`${declaration.workspace}: dependency is not installed.`)
132+
}
133+
}
134+
} catch (error) {
135+
reasons.push(
136+
`${declaration.workspace}: ${error instanceof Error ? error.message : String(error)}`,
137+
)
138+
}
139+
reports.push(report.join('\n'))
140+
}
141+
if (installedDirectories.size > 1) {
142+
reasons.push(
143+
'Workspaces resolve to different installed copies, even if their versions match.',
144+
)
145+
}
146+
if (reasons.length > 0) {
147+
failures.push(`${dependency}\n${reasons.join('\n')}\n${reports.join('\n')}`)
148+
}
149+
}
150+
151+
if (skipped.length > 0) {
152+
console.log(`Explicit dependency exceptions:\n${skipped.join('\n')}`)
153+
}
154+
if (failures.length > 0) {
155+
console.error(
156+
`Dependency consistency check failed:\n\n${failures.join('\n\n')}`,
157+
)
158+
console.error(
159+
'\nAlign the dependency declarations, run yarn install, and rerun yarn check-dependencies. Intentional differences require a reason in package.json dependencyConsistency.exceptions.',
160+
)
161+
process.exitCode = 1
162+
} else {
163+
console.log(
164+
`Dependency consistency check passed: ${checked} shared dependencies have matching declarations and resolve to one installed copy.`,
165+
)
166+
}

0 commit comments

Comments
 (0)