-
Notifications
You must be signed in to change notification settings - Fork 12
ci: add dynamic import verification to catch stale paths #540
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
0d03ec2
38f4182
ab315f7
dbdeefd
3ee1b89
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,127 @@ | ||||||||||||||||||
| #!/usr/bin/env node | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Verify that all dynamic import() paths in src/ resolve to existing files. | ||||||||||||||||||
| * | ||||||||||||||||||
| * Catches stale paths left behind after moves/renames — the class of bug | ||||||||||||||||||
| * that caused the ast-command crash (see roadmap 10.3). | ||||||||||||||||||
| * | ||||||||||||||||||
| * Exit codes: | ||||||||||||||||||
| * 0 — all imports resolve | ||||||||||||||||||
| * 1 — one or more broken imports found | ||||||||||||||||||
| */ | ||||||||||||||||||
|
|
||||||||||||||||||
| import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; | ||||||||||||||||||
| import { resolve, dirname, join, extname } from 'node:path'; | ||||||||||||||||||
| import { fileURLToPath } from 'node:url'; | ||||||||||||||||||
|
|
||||||||||||||||||
| const __filename = fileURLToPath(import.meta.url); | ||||||||||||||||||
| const __dirname = dirname(__filename); | ||||||||||||||||||
| const srcDir = resolve(__dirname, '..', 'src'); | ||||||||||||||||||
|
|
||||||||||||||||||
| // ── collect source files ──────────────────────────────────────────────── | ||||||||||||||||||
| function walk(dir) { | ||||||||||||||||||
| const results = []; | ||||||||||||||||||
| for (const entry of readdirSync(dir, { withFileTypes: true })) { | ||||||||||||||||||
| const full = join(dir, entry.name); | ||||||||||||||||||
| if (entry.isDirectory()) { | ||||||||||||||||||
| if (entry.name === 'node_modules') continue; | ||||||||||||||||||
| results.push(...walk(full)); | ||||||||||||||||||
| } else if (/\.[jt]sx?$/.test(entry.name)) { | ||||||||||||||||||
| results.push(full); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| return results; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // ── extract dynamic import specifiers ─────────────────────────────────── | ||||||||||||||||||
| // Matches: await import('...') and await import("...") | ||||||||||||||||||
| const DYNAMIC_IMPORT_RE = /await\s+import\(\s*(['"])(.+?)\1\s*\)/g; | ||||||||||||||||||
|
|
||||||||||||||||||
| function extractDynamicImports(filePath) { | ||||||||||||||||||
| const src = readFileSync(filePath, 'utf8'); | ||||||||||||||||||
| const imports = []; | ||||||||||||||||||
| const lines = src.split('\n'); | ||||||||||||||||||
|
|
||||||||||||||||||
| let inBlockComment = false; | ||||||||||||||||||
| for (let i = 0; i < lines.length; i++) { | ||||||||||||||||||
| const line = lines[i]; | ||||||||||||||||||
|
|
||||||||||||||||||
| // Track block comments (/** ... */ and /* ... */) | ||||||||||||||||||
| if (inBlockComment) { | ||||||||||||||||||
| if (line.includes('*/')) inBlockComment = false; | ||||||||||||||||||
| continue; | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+71
to
+76
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the closing // current (buggy):
if (inBlockComment) {
if (line.includes('*/')) inBlockComment = false;
continue; // ← skips ALL of the current line, including code after */
}A minimal fix is to skip the if (inBlockComment) {
const closeIdx = line.indexOf('*/');
if (closeIdx === -1) continue; // still fully inside a block comment
inBlockComment = false;
// fall through with only the part of the line that follows '*/'
scanLine = line.slice(closeIdx + 2);
// skip re-assigning scanLine = line below, then proceed to import scanning
}Because
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 3ee1b89 — when |
||||||||||||||||||
| if (/^\s*\/\*/.test(line)) { | ||||||||||||||||||
| if (!line.includes('*/')) inBlockComment = true; | ||||||||||||||||||
| continue; | ||||||||||||||||||
| } | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
const x = callSomething(); /* begin multi-line comment
await import('./stale-path.js') ← this line IS inside a comment but WILL be scanned
*/On the line containing Consider tracking the opening position more broadly:
Suggested change
Or strip block comments from the line before applying the regex, rather than using a line-level guard.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — removed the line-start-only |
||||||||||||||||||
| // Skip single-line comments | ||||||||||||||||||
| if (/^\s*\/\//.test(line)) continue; | ||||||||||||||||||
|
|
||||||||||||||||||
| let match; | ||||||||||||||||||
| DYNAMIC_IMPORT_RE.lastIndex = 0; | ||||||||||||||||||
| while ((match = DYNAMIC_IMPORT_RE.exec(line)) !== null) { | ||||||||||||||||||
| // Skip if the match is inside a trailing comment | ||||||||||||||||||
| const before = line.slice(0, match.index); | ||||||||||||||||||
| if (before.includes('//') || before.includes('/*')) continue; | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The check const base = 'https://example.com'; const { mod } = await import('./missing-path.js');The A safer heuristic is to check whether
Suggested change
Alternatively, use a stricter check that only skips when
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — replaced naive \ with a new \ helper that walks character-by-character tracking quote state (single, double, backtick). Only flags \ that appears outside string literals, so URLs like \ no longer cause false negatives. |
||||||||||||||||||
|
|
||||||||||||||||||
| imports.push({ specifier: match[2], line: i + 1 }); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| return imports; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // ── resolve a specifier to a file on disk ─────────────────────────────── | ||||||||||||||||||
| function resolveSpecifier(specifier, fromFile) { | ||||||||||||||||||
| // Skip bare specifiers (packages): 'node:*', '@scope/pkg', 'pkg' | ||||||||||||||||||
| if (!specifier.startsWith('.') && !specifier.startsWith('/')) return null; | ||||||||||||||||||
|
|
||||||||||||||||||
| const base = dirname(fromFile); | ||||||||||||||||||
| const target = resolve(base, specifier); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Exact file exists | ||||||||||||||||||
| if (existsSync(target) && statSync(target).isFile()) return null; | ||||||||||||||||||
|
|
||||||||||||||||||
| // Try implicit extensions (.js, .ts, .mjs, .cjs) | ||||||||||||||||||
| for (const ext of ['.js', '.ts', '.mjs', '.cjs']) { | ||||||||||||||||||
| if (!extname(target) && existsSync(target + ext)) return null; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Try index files (directory import) | ||||||||||||||||||
| if (existsSync(target) && statSync(target).isDirectory()) { | ||||||||||||||||||
| for (const idx of ['index.js', 'index.ts', 'index.mjs']) { | ||||||||||||||||||
| if (existsSync(join(target, idx))) return null; | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Not resolved — broken | ||||||||||||||||||
| return specifier; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // ── main ──────────────────────────────────────────────────────────────── | ||||||||||||||||||
| const files = walk(srcDir); | ||||||||||||||||||
| const broken = []; | ||||||||||||||||||
|
|
||||||||||||||||||
| for (const file of files) { | ||||||||||||||||||
| const imports = extractDynamicImports(file); | ||||||||||||||||||
| for (const { specifier, line } of imports) { | ||||||||||||||||||
| const bad = resolveSpecifier(specifier, file); | ||||||||||||||||||
| if (bad !== null) { | ||||||||||||||||||
| const rel = file.replace(resolve(srcDir, '..') + '/', '').replace(/\\/g, '/'); | ||||||||||||||||||
| broken.push({ file: rel, line, specifier: bad }); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| if (broken.length === 0) { | ||||||||||||||||||
| console.log(`✓ All dynamic imports in src/ resolve (${files.length} files scanned)`); | ||||||||||||||||||
| process.exit(0); | ||||||||||||||||||
| } else { | ||||||||||||||||||
| console.error(`✗ ${broken.length} broken dynamic import(s) found:\n`); | ||||||||||||||||||
| for (const { file, line, specifier } of broken) { | ||||||||||||||||||
| console.error(` ${file}:${line} → ${specifier}`); | ||||||||||||||||||
| } | ||||||||||||||||||
| console.error('\nFix the import paths and re-run.'); | ||||||||||||||||||
| process.exit(1); | ||||||||||||||||||
| } | ||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
import()calls are invisible to this scriptThe regex only matches
await import(...). A dynamic import that is returned,.then()-chained, or assigned withoutawaitwill be entirely skipped:If the codebase ever uses these patterns, a stale path would bypass this check entirely. Consider broadening the regex to match bare
import(...)calls as well:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed — broadened the regex from
/await\s+import\(...\)/to/(?:await\s+)?import\(...\)/so it now catches bareimport()calls:return import(...),import(...).then(...), and variable assignments without await.