Skip to content

Commit 799fe7a

Browse files
authored
feat: add ghost dependency lint + CI enforcement (#4546)
1 parent 30c6bcb commit 799fe7a

File tree

3 files changed

+278
-1
lines changed

3 files changed

+278
-1
lines changed

.github/workflows/lint-deps.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Lint Dependencies
2+
3+
permissions:
4+
contents: read
5+
6+
on:
7+
pull_request:
8+
branches: [main, '**']
9+
push:
10+
branches: [main]
11+
12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
lint-deps:
18+
runs-on: ubuntu-latest
19+
timeout-minutes: 30
20+
steps:
21+
- name: Checkout Repository
22+
uses: actions/checkout@v5
23+
with:
24+
fetch-depth: 0
25+
26+
- name: Setup pnpm
27+
uses: pnpm/action-setup@v4
28+
29+
- name: Setup Node.js 20
30+
uses: actions/setup-node@v6
31+
with:
32+
node-version: '20'
33+
cache: 'pnpm'
34+
cache-dependency-path: '**/pnpm-lock.yaml'
35+
36+
- name: Install dependencies
37+
run: pnpm install --frozen-lockfile
38+
39+
- name: Check for ghost dependencies
40+
run: pnpm run lint:deps

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@
8484
"commitgen:staged": "./commit-gen.js --path ./packages --staged",
8585
"commitgen:main": "./commit-gen.js --path ./packages",
8686
"changeset:status": "changeset status",
87-
"generate:schema": "pnpm --filter @module-federation/enhanced run generate:schema && git diff --name-only | xargs pnpm exec prettier --write --ignore-unknown"
87+
"generate:schema": "pnpm --filter @module-federation/enhanced run generate:schema && git diff --name-only | xargs pnpm exec prettier --write --ignore-unknown",
88+
"lint:deps": "node scripts/check-ghost-deps.mjs"
8889
},
8990
"pnpm": {
9091
"packageExtensions": {

scripts/check-ghost-deps.mjs

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
#!/usr/bin/env node
2+
/**
3+
* check-ghost-deps.mjs
4+
*
5+
* Scans import/require statements in src/ directories under packages/*,
6+
* identifying third-party dependencies not declared in package.json (ghost dependencies).
7+
*
8+
* Usage:
9+
* node scripts/check-ghost-deps.mjs # Detect and report errors
10+
* node scripts/check-ghost-deps.mjs --fix # Print fix suggestions (pnpm add commands)
11+
*/
12+
13+
import fs from 'node:fs';
14+
import path from 'node:path';
15+
16+
const FIX_MODE = process.argv.includes('--fix');
17+
const ROOT = path.resolve(import.meta.dirname, '..');
18+
const PACKAGES_DIR = path.join(ROOT, 'packages');
19+
20+
// Node.js built-in module prefixes
21+
const NODE_BUILTINS = new Set([
22+
'assert',
23+
'async_hooks',
24+
'buffer',
25+
'child_process',
26+
'cluster',
27+
'console',
28+
'constants',
29+
'crypto',
30+
'dgram',
31+
'diagnostics_channel',
32+
'dns',
33+
'domain',
34+
'events',
35+
'fs',
36+
'http',
37+
'http2',
38+
'https',
39+
'inspector',
40+
'module',
41+
'net',
42+
'os',
43+
'path',
44+
'perf_hooks',
45+
'process',
46+
'punycode',
47+
'querystring',
48+
'readline',
49+
'repl',
50+
'stream',
51+
'string_decoder',
52+
'sys',
53+
'timers',
54+
'tls',
55+
'trace_events',
56+
'tty',
57+
'url',
58+
'util',
59+
'v8',
60+
'vm',
61+
'wasi',
62+
'worker_threads',
63+
'zlib',
64+
]);
65+
66+
// Workspace internal package prefixes (not ghost dependencies)
67+
const WORKSPACE_PREFIXES = ['@module-federation/'];
68+
69+
// Virtual module / alias prefixes (skip)
70+
const VIRTUAL_PREFIXES = [
71+
'virtual:',
72+
'\0',
73+
'@/',
74+
'~/',
75+
'mf:',
76+
'REMOTE_ALIAS_IDENTIFIER',
77+
];
78+
79+
// Known virtual specifiers (exact match, skip)
80+
const VIRTUAL_EXACT = new Set([
81+
'federation-host',
82+
'federationShare',
83+
'ignored-modules',
84+
// Test mocks / internal aliases (not real npm packages)
85+
'foo',
86+
'ui-lib',
87+
'REMOTE_ALIAS_IDENTIFIER',
88+
]);
89+
90+
/**
91+
* Determine if a specifier should be skipped
92+
*/
93+
function shouldSkip(spec) {
94+
if (!spec) return true;
95+
if (spec.startsWith('.') || spec.startsWith('/')) return true; // relative/absolute paths
96+
if (spec.startsWith('node:')) return true; // node: protocol
97+
// Template string interpolation leftovers (e.g. `${foo}/bar`)
98+
if (spec.includes('${')) return true;
99+
// All-uppercase identifiers (macros/constants, not package names)
100+
if (/^[A-Z_]+$/.test(spec)) return true;
101+
const bare = spec.split('/')[0];
102+
if (NODE_BUILTINS.has(bare)) return true;
103+
if (WORKSPACE_PREFIXES.some((p) => spec.startsWith(p))) return true;
104+
if (VIRTUAL_PREFIXES.some((p) => spec.startsWith(p))) return true;
105+
if (VIRTUAL_EXACT.has(spec)) return true;
106+
return false;
107+
}
108+
109+
/**
110+
* Extract package name from specifier (handles @scope/pkg and regular pkg)
111+
*/
112+
function extractPkgName(spec) {
113+
if (spec.startsWith('@')) {
114+
const parts = spec.split('/');
115+
return parts.slice(0, 2).join('/');
116+
}
117+
return spec.split('/')[0];
118+
}
119+
120+
/**
121+
* Recursively traverse directory, returning all files matching given extensions
122+
*/
123+
function walkDir(dir, exts) {
124+
const results = [];
125+
if (!fs.existsSync(dir)) return results;
126+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
127+
const full = path.join(dir, entry.name);
128+
if (entry.isDirectory()) {
129+
results.push(...walkDir(full, exts));
130+
} else if (exts.some((e) => entry.name.endsWith(e))) {
131+
results.push(full);
132+
}
133+
}
134+
return results;
135+
}
136+
137+
/**
138+
* Extract all import/require specifiers from file content (using regex, not full AST)
139+
*/
140+
function extractSpecifiers(content) {
141+
const specs = new Set();
142+
// static import/export: import ... from 'xxx' / export ... from 'xxx'
143+
for (const m of content.matchAll(
144+
/(?:import|export)\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g,
145+
)) {
146+
specs.add(m[1]);
147+
}
148+
// dynamic import: import('xxx')
149+
for (const m of content.matchAll(/import\(\s*['"]([^'"]+)['"]\s*\)/g)) {
150+
specs.add(m[1]);
151+
}
152+
// require('xxx')
153+
for (const m of content.matchAll(/require\(\s*['"]([^'"]+)['"]\s*\)/g)) {
154+
specs.add(m[1]);
155+
}
156+
return specs;
157+
}
158+
159+
// ---- Main logic ----
160+
161+
let hasError = false;
162+
const errorSummary = []; // { pkgName, pkgDir, missing: Set<string> }
163+
164+
const pkgDirs = fs
165+
.readdirSync(PACKAGES_DIR, { withFileTypes: true })
166+
.filter((e) => e.isDirectory())
167+
.map((e) => path.join(PACKAGES_DIR, e.name));
168+
169+
for (const pkgDir of pkgDirs) {
170+
const pkgJsonPath = path.join(pkgDir, 'package.json');
171+
if (!fs.existsSync(pkgJsonPath)) continue;
172+
173+
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
174+
const pkgName = pkgJson.name ?? path.basename(pkgDir);
175+
176+
const declared = new Set([
177+
...Object.keys(pkgJson.dependencies ?? {}),
178+
...Object.keys(pkgJson.devDependencies ?? {}),
179+
...Object.keys(pkgJson.peerDependencies ?? {}),
180+
...Object.keys(pkgJson.optionalDependencies ?? {}),
181+
]);
182+
183+
// Scan src/ directory (some packages may have lib/ or root directory, also scan one level as fallback)
184+
const srcDir = path.join(pkgDir, 'src');
185+
const files = walkDir(srcDir, ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
186+
187+
const missing = new Set();
188+
189+
for (const file of files) {
190+
let content;
191+
try {
192+
content = fs.readFileSync(file, 'utf8');
193+
} catch {
194+
continue;
195+
}
196+
for (const spec of extractSpecifiers(content)) {
197+
if (shouldSkip(spec)) continue;
198+
const pkg = extractPkgName(spec);
199+
if (!declared.has(pkg)) {
200+
missing.add(pkg);
201+
}
202+
}
203+
}
204+
205+
if (missing.size > 0) {
206+
hasError = true;
207+
errorSummary.push({ pkgName, pkgDir, missing });
208+
console.error(
209+
`\n❌ [${pkgName}] Found ghost dependencies (${missing.size} total):`,
210+
);
211+
for (const dep of [...missing].sort()) {
212+
console.error(` - ${dep}`);
213+
}
214+
if (FIX_MODE) {
215+
const deps = [...missing].sort().join(' ');
216+
console.log(`\n 💡 Fix suggestion:`);
217+
console.log(` pnpm --filter ${pkgName} add ${deps}`);
218+
}
219+
}
220+
}
221+
222+
if (hasError) {
223+
console.error(
224+
`\n\n💥 Ghost dependencies detected! Please add declarations to the corresponding package.json files.`,
225+
);
226+
if (!FIX_MODE) {
227+
console.error(
228+
` Tip: Run node scripts/check-ghost-deps.mjs --fix to see fix suggestions`,
229+
);
230+
}
231+
process.exit(1);
232+
} else {
233+
console.log(
234+
'✅ No ghost dependencies found. All package dependencies are properly declared.',
235+
);
236+
}

0 commit comments

Comments
 (0)