Skip to content

Commit 0e09359

Browse files
authored
Merge branch 'main' into fix/incremental-edge-gap-533
2 parents 4cf64d8 + ccafc60 commit 0e09359

6 files changed

Lines changed: 186 additions & 10 deletions

File tree

.github/workflows/ci.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,20 @@ jobs:
101101
- name: Type check
102102
run: npm run typecheck
103103

104+
verify-imports:
105+
runs-on: ubuntu-latest
106+
name: Verify dynamic imports
107+
steps:
108+
- uses: actions/checkout@v6
109+
110+
- name: Setup Node.js
111+
uses: actions/setup-node@v6
112+
with:
113+
node-version: 22
114+
115+
- name: Verify all dynamic imports resolve
116+
run: node scripts/verify-imports.js
117+
104118
rust-check:
105119
runs-on: ubuntu-latest
106120
name: Rust compile check
@@ -121,7 +135,7 @@ jobs:
121135

122136
ci-pipeline:
123137
if: always()
124-
needs: [lint, test, typecheck, rust-check]
138+
needs: [lint, test, typecheck, verify-imports, rust-check]
125139
runs-on: ubuntu-latest
126140
name: CI Testing Pipeline
127141
steps:

crates/codegraph-core/src/edge_builder.rs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ use napi_derive::napi;
44

55
use crate::import_resolution;
66

7+
/// Kind sets for hierarchy edge resolution -- mirrors the JS constants in
8+
/// `build-edges.js` (`HIERARCHY_SOURCE_KINDS`, `EXTENDS_TARGET_KINDS`,
9+
/// `IMPLEMENTS_TARGET_KINDS`). Keeping them in one place prevents the
10+
/// native/WASM drift that caused the original parity bug.
11+
const HIERARCHY_SOURCE_KINDS: &[&str] = &["class", "struct", "record", "enum"];
12+
const EXTENDS_TARGET_KINDS: &[&str] = &["class", "struct", "trait", "record"];
13+
const IMPLEMENTS_TARGET_KINDS: &[&str] = &["interface", "trait", "class"];
14+
715
#[napi(object)]
816
pub struct NodeInfo {
917
pub id: u32,
@@ -339,16 +347,14 @@ pub fn build_call_edges(
339347
for cls in &file_input.classes {
340348
let source_row = nodes_by_name_and_file
341349
.get(&(cls.name.as_str(), rel_path.as_str()))
342-
.and_then(|v| v.iter().find(|n| {
343-
n.kind == "class" || n.kind == "struct" || n.kind == "record" || n.kind == "enum"
344-
}));
350+
.and_then(|v| v.iter().find(|n| HIERARCHY_SOURCE_KINDS.contains(&n.kind.as_str())));
345351

346352
if let Some(source) = source_row {
347353
if let Some(ref extends_name) = cls.extends {
348354
let targets = nodes_by_name
349355
.get(extends_name.as_str())
350356
.map(|v| v.iter().filter(|n| {
351-
n.kind == "class" || n.kind == "struct" || n.kind == "trait" || n.kind == "record"
357+
EXTENDS_TARGET_KINDS.contains(&n.kind.as_str())
352358
}).collect::<Vec<_>>())
353359
.unwrap_or_default();
354360
for t in targets {
@@ -366,7 +372,7 @@ pub fn build_call_edges(
366372
.get(implements_name.as_str())
367373
.map(|v| {
368374
v.iter()
369-
.filter(|n| n.kind == "interface" || n.kind == "class" || n.kind == "trait")
375+
.filter(|n| IMPLEMENTS_TARGET_KINDS.contains(&n.kind.as_str()))
370376
.collect::<Vec<_>>()
371377
})
372378
.unwrap_or_default();

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"build": "tsc",
3333
"build:wasm": "node scripts/build-wasm.js",
3434
"typecheck": "tsc --noEmit",
35+
"verify-imports": "node scripts/verify-imports.js",
3536
"test": "vitest run",
3637
"test:watch": "vitest",
3738
"test:coverage": "vitest run --coverage",

scripts/verify-imports.js

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Verify that all dynamic import() paths in src/ resolve to existing files.
5+
*
6+
* Catches stale paths left behind after moves/renames — the class of bug
7+
* that caused the ast-command crash (see roadmap 10.3).
8+
*
9+
* Exit codes:
10+
* 0 — all imports resolve
11+
* 1 — one or more broken imports found
12+
*/
13+
14+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
15+
import { resolve, dirname, join, extname } from 'node:path';
16+
import { fileURLToPath } from 'node:url';
17+
18+
const __filename = fileURLToPath(import.meta.url);
19+
const __dirname = dirname(__filename);
20+
const srcDir = resolve(__dirname, '..', 'src');
21+
22+
// ── collect source files ────────────────────────────────────────────────
23+
function walk(dir) {
24+
const results = [];
25+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
26+
const full = join(dir, entry.name);
27+
if (entry.isDirectory()) {
28+
if (entry.name === 'node_modules') continue;
29+
results.push(...walk(full));
30+
} else if (/\.[jt]sx?$/.test(entry.name)) {
31+
results.push(full);
32+
}
33+
}
34+
return results;
35+
}
36+
37+
// ── extract dynamic import specifiers ───────────────────────────────────
38+
// Matches: import('...') and import("...") — with or without await
39+
const DYNAMIC_IMPORT_RE = /(?:await\s+)?import\(\s*(['"])(.+?)\1\s*\)/g;
40+
41+
/**
42+
* Check whether the text contains a `//` line-comment marker that is NOT
43+
* inside a string literal. Walks character-by-character tracking quote state.
44+
*/
45+
function isInsideLineComment(text) {
46+
let inStr = null; // null | "'" | '"' | '`'
47+
for (let i = 0; i < text.length; i++) {
48+
const ch = text[i];
49+
if (ch === '\\' && inStr) { i++; continue; } // skip escaped char
50+
if (inStr) {
51+
if (ch === inStr) inStr = null;
52+
continue;
53+
}
54+
if (ch === "'" || ch === '"' || ch === '`') { inStr = ch; continue; }
55+
if (ch === '/' && text[i + 1] === '/') return true;
56+
}
57+
return false;
58+
}
59+
60+
function extractDynamicImports(filePath) {
61+
const src = readFileSync(filePath, 'utf8');
62+
const imports = [];
63+
const lines = src.split('\n');
64+
65+
let inBlockComment = false;
66+
for (let i = 0; i < lines.length; i++) {
67+
const line = lines[i];
68+
69+
// Track block comments (/** ... */ and /* ... */)
70+
let scanLine = line;
71+
if (inBlockComment) {
72+
const closeIdx = scanLine.indexOf('*/');
73+
if (closeIdx === -1) continue; // still fully inside a block comment
74+
inBlockComment = false;
75+
scanLine = scanLine.slice(closeIdx + 2); // scan content after */
76+
}
77+
// Skip single-line comments
78+
if (/^\s*\/\//.test(scanLine)) continue;
79+
if (scanLine.includes('/*')) {
80+
// Remove fully closed inline block comments: code /* ... */ more code
81+
scanLine = scanLine.replace(/\/\*.*?\*\//g, '');
82+
// If an unclosed /* remains, keep only the part before it and enter block mode
83+
const openIdx = scanLine.indexOf('/*');
84+
if (openIdx !== -1) {
85+
scanLine = scanLine.slice(0, openIdx);
86+
inBlockComment = true;
87+
}
88+
}
89+
90+
let match;
91+
DYNAMIC_IMPORT_RE.lastIndex = 0;
92+
while ((match = DYNAMIC_IMPORT_RE.exec(scanLine)) !== null) {
93+
// Skip if the match is inside a trailing line comment (// outside quotes)
94+
const before = scanLine.slice(0, match.index);
95+
if (isInsideLineComment(before)) continue;
96+
97+
imports.push({ specifier: match[2], line: i + 1 });
98+
}
99+
}
100+
return imports;
101+
}
102+
103+
// ── resolve a specifier to a file on disk ───────────────────────────────
104+
function resolveSpecifier(specifier, fromFile) {
105+
// Skip bare specifiers (packages): 'node:*', '@scope/pkg', 'pkg'
106+
if (!specifier.startsWith('.') && !specifier.startsWith('/')) return null;
107+
108+
const base = dirname(fromFile);
109+
const target = resolve(base, specifier);
110+
111+
// Exact file exists
112+
if (existsSync(target) && statSync(target).isFile()) return null;
113+
114+
// Try implicit extensions (.js, .ts, .mjs, .cjs)
115+
for (const ext of ['.js', '.ts', '.mjs', '.cjs']) {
116+
if (!extname(target) && existsSync(target + ext)) return null;
117+
}
118+
119+
// Try index files (directory import)
120+
if (existsSync(target) && statSync(target).isDirectory()) {
121+
for (const idx of ['index.js', 'index.ts', 'index.mjs']) {
122+
if (existsSync(join(target, idx))) return null;
123+
}
124+
}
125+
126+
// Not resolved — broken
127+
return specifier;
128+
}
129+
130+
// ── main ────────────────────────────────────────────────────────────────
131+
const files = walk(srcDir);
132+
const broken = [];
133+
134+
for (const file of files) {
135+
const imports = extractDynamicImports(file);
136+
for (const { specifier, line } of imports) {
137+
const bad = resolveSpecifier(specifier, file);
138+
if (bad !== null) {
139+
const rel = file.replace(resolve(srcDir, '..') + '/', '').replace(/\\/g, '/');
140+
broken.push({ file: rel, line, specifier: bad });
141+
}
142+
}
143+
}
144+
145+
if (broken.length === 0) {
146+
console.log(`✓ All dynamic imports in src/ resolve (${files.length} files scanned)`);
147+
process.exit(0);
148+
} else {
149+
console.error(`✗ ${broken.length} broken dynamic import(s) found:\n`);
150+
for (const { file, line, specifier } of broken) {
151+
console.error(` ${file}:${line}${specifier}`);
152+
}
153+
console.error('\nFix the import paths and re-run.');
154+
process.exit(1);
155+
}

src/cli/commands/info.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const command = {
3636
console.log();
3737

3838
try {
39-
const { findDbPath, getBuildMeta } = await import('../../db.js');
39+
const { findDbPath, getBuildMeta } = await import('../../db/index.js');
4040
const Database = (await import('better-sqlite3')).default;
4141
const dbPath = findDbPath();
4242
const fs = await import('node:fs');

src/mcp/tools/semantic-search.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export async function handler(args, ctx) {
1111
};
1212

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

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

4747
// hybrid (default) — falls back to semantic if no FTS5
48-
const { hybridSearchData, searchData } = await import('../../embeddings/index.js');
48+
const { hybridSearchData, searchData } = await import('../../domain/search/index.js');
4949
let result = await hybridSearchData(args.query, ctx.dbPath, searchOpts);
5050
if (result === null) {
5151
result = await searchData(args.query, ctx.dbPath, searchOpts);

0 commit comments

Comments
 (0)