Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,20 @@ jobs:
- name: Type check
run: npm run typecheck

verify-imports:
runs-on: ubuntu-latest
name: Verify dynamic imports
steps:
- uses: actions/checkout@v6

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22

- name: Verify all dynamic imports resolve
run: node scripts/verify-imports.js

rust-check:
runs-on: ubuntu-latest
name: Rust compile check
Expand All @@ -121,7 +135,7 @@ jobs:

ci-pipeline:
if: always()
needs: [lint, test, typecheck, rust-check]
needs: [lint, test, typecheck, verify-imports, rust-check]
runs-on: ubuntu-latest
name: CI Testing Pipeline
steps:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"build": "tsc",
"build:wasm": "node scripts/build-wasm.js",
"typecheck": "tsc --noEmit",
"verify-imports": "node scripts/verify-imports.js",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
Expand Down
127 changes: 127 additions & 0 deletions scripts/verify-imports.js
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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Non-awaited import() calls are invisible to this script

The regex only matches await import(...). A dynamic import that is returned, .then()-chained, or assigned without await will be entirely skipped:

return import('./stale-path.js');          // not caught
import('./stale-path.js').then(doSomething); // not caught

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:

Suggested change
const DYNAMIC_IMPORT_RE = /await\s+import\(\s*(['"])(.+?)\1\s*\)/g;
const DYNAMIC_IMPORT_RE = /(?:await\s+)?import\(\s*(['"])(.+?)\1\s*\)/g;

Copy link
Copy Markdown
Contributor Author

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 bare import() calls: return import(...), import(...).then(...), and variable assignments without await.


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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 False-negative: content after */ on same line is never scanned

When the closing */ of a block comment appears mid-line (e.g. */ await import('./stale.js');), inBlockComment is set to false but the continue unconditionally skips the rest of the current line. Any real import() call after the */ is invisible to the scanner — exactly the kind of false negative this tool is meant to prevent.

// 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 continue when */ is found and instead pass the post-close slice into scanLine:

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 scanLine is currently declared a few lines later (let scanLine = line;), the cleanest approach is to hoist the declaration and branch early, similar to how the block-comment stripping already works for mid-line /* openings.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3ee1b89 — when */ is found mid-line, the scanner now extracts the content after the close (scanLine = line.slice(closeIdx + 2)) and falls through to the import scanning logic instead of unconditionally skipping the line with continue.

if (/^\s*\/\*/.test(line)) {
if (!line.includes('*/')) inBlockComment = true;
continue;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Block-comment tracking misses mid-line /* openings

inBlockComment is only set when a line matches /^\s*\/\*/ — i.e., /* at the beginning of the line. A /* that appears after real code is ignored:

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 await import('./stale-path.js') the state inBlockComment is still false, so it gets scanned and could produce either a false positive (valid path inside comment flagged) or miss a stale path in code.

Consider tracking the opening position more broadly:

Suggested change
if (/^\s*\/\*/.test(line)) {
if (!line.includes('*/')) inBlockComment = true;
continue;
}
if (!inBlockComment && line.includes('/*')) {
if (!line.includes('*/')) inBlockComment = true;
// Still check content before the /*
}

Or strip block comments from the line before applying the regex, rather than using a line-level guard.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — removed the line-start-only ^\s*\/\* check. Block comments are now stripped from each line before regex scanning: inline /* ... */ pairs are removed with a greedy replace, and if an unclosed /* remains, we truncate the line at that point and enter inBlockComment mode. This correctly handles mid-line openings like const x = foo(); /* begin comment.

// 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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 False-negative: // inside string literals silently skips real imports

The check before.includes('//') is meant to detect trailing // comments, but it matches any occurrence of // in the preceding text — including URLs embedded in string literals. For example:

const base = 'https://example.com'; const { mod } = await import('./missing-path.js');

The before slice here contains //, so the import is silently skipped and the broken path goes undetected — exactly the opposite of what this script is meant to do.

A safer heuristic is to check whether // appears outside quoted strings, or at minimum check that it is preceded only by whitespace or non-string characters. A simple approach is to scan for the first unquoted //:

Suggested change
const before = line.slice(0, match.index);
if (before.includes('//') || before.includes('/*')) continue;
// Skip if the match is inside a trailing comment (simple heuristic: look for // outside quotes)
const before = line.slice(0, match.index);
const trailingCommentIdx = before.search(/(?<!['"]\S*)\/\//);
if (trailingCommentIdx !== -1 || before.includes('/*')) continue;

Alternatively, use a stricter check that only skips when // or /* is not preceded by a quote character on the same line.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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);
}
2 changes: 1 addition & 1 deletion src/cli/commands/info.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const command = {
console.log();

try {
const { findDbPath, getBuildMeta } = await import('../../db.js');
const { findDbPath, getBuildMeta } = await import('../../db/index.js');
const Database = (await import('better-sqlite3')).default;
const dbPath = findDbPath();
const fs = await import('node:fs');
Expand Down
6 changes: 3 additions & 3 deletions src/mcp/tools/semantic-search.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export async function handler(args, ctx) {
};

if (mode === 'keyword') {
const { ftsSearchData } = await import('../../embeddings/index.js');
const { ftsSearchData } = await import('../../domain/search/index.js');
const result = ftsSearchData(args.query, ctx.dbPath, searchOpts);
if (result === null) {
return {
Expand All @@ -28,7 +28,7 @@ export async function handler(args, ctx) {
}

if (mode === 'semantic') {
const { searchData } = await import('../../embeddings/index.js');
const { searchData } = await import('../../domain/search/index.js');
const result = await searchData(args.query, ctx.dbPath, searchOpts);
if (result === null) {
return {
Expand All @@ -45,7 +45,7 @@ export async function handler(args, ctx) {
}

// hybrid (default) — falls back to semantic if no FTS5
const { hybridSearchData, searchData } = await import('../../embeddings/index.js');
const { hybridSearchData, searchData } = await import('../../domain/search/index.js');
let result = await hybridSearchData(args.query, ctx.dbPath, searchOpts);
if (result === null) {
result = await searchData(args.query, ctx.dbPath, searchOpts);
Expand Down
Loading