Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
22 changes: 18 additions & 4 deletions crates/codegraph-core/src/cfg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -781,10 +781,24 @@ impl<'a> CfgBuilder<'a> {
self.loop_stack.push(LoopCtx { header_idx: header, exit_idx: exit, is_loop: true });
self.update_label_map(header, exit);

// Check if this for loop has a condition — if not (e.g. Go `for {}`), treat as infinite loop
let has_condition = self.rules.condition_field
.and_then(|f| for_stmt.child_by_field_name(f))
.is_some();
// Determine if this for-loop is bounded. C-style for loops have a `condition`
// field; iterator-style loops (for-in, foreach, enhanced-for, Rust for) have an
// iterable field (`right` or `value`) or a known iterator node kind. Only Go's
// bare `for {}` has none of these and is truly infinite.
let has_condition = match self.rules.condition_field {
None => true, // Language has no C-style for (e.g. Rust) — all for-nodes are bounded
Some(field) => {
for_stmt.child_by_field_name(field).is_some()
|| for_stmt.child_by_field_name("right").is_some()
|| for_stmt.child_by_field_name("value").is_some()
// Explicit iterator-style node kinds across supported languages:
// JS: for_in_statement, Java: enhanced_for_statement,
// C#/PHP: foreach_statement, Ruby: for
|| matches!(for_stmt.kind(),
"for_in_statement" | "enhanced_for_statement" |
"foreach_statement" | "for")
}
};

let body = for_stmt.child_by_field_name("body");
let body_block = self.make_block("loop_body", None, None, None);
Expand Down
10 changes: 6 additions & 4 deletions crates/codegraph-core/src/extractors/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,11 +254,13 @@ pub fn walk_ast_nodes_with_config(
// - Rust raw strings `r"..."`, `r#"..."#`
// - Python prefixes: r, b, f, u and combos like rb, fr
let without_prefix = raw.trim_start_matches('@')
.trim_start_matches(|c: char| config.string_prefixes.contains(&c))
.trim_start_matches('r');
// Only strip `#` delimiters for raw string node types (e.g. Rust `r#"..."#`)
.trim_start_matches(|c: char| config.string_prefixes.contains(&c));
// For raw string node types (e.g. Rust `r#"..."#`), strip the `r` prefix
// and `#` delimiters. This must be conditional — the unconditional
// `.trim_start_matches('r')` that was here before double-stripped 'r' for
// languages like Python where 'r' is already in string_prefixes.
let without_prefix = if is_raw_string {
without_prefix.trim_start_matches('#')
without_prefix.trim_start_matches('r').trim_start_matches('#')
} else {
without_prefix
};
Expand Down
48 changes: 46 additions & 2 deletions scripts/lib/bench-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,59 @@ export async function resolveBenchmarkSource() {
}

const pkgDir = path.join(tmpDir, 'node_modules', '@optave', 'codegraph');

const installedPkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));

// npm does not transitively install optionalDependencies of a dependency,
// so the platform-specific native binary is missing. Install it explicitly.
try {
const optDeps = installedPkg.optionalDependencies || {};
const platform = os.platform();
const arch = os.arch();
let libcSuffix = '';
if (platform === 'linux') {
try {
const files = fs.readdirSync('/lib');
libcSuffix = files.some((f) => f.startsWith('ld-musl-') && f.endsWith('.so.1')) ? '-musl' : '-gnu';
} catch {
libcSuffix = '-gnu';
}
}
const platformKey = `codegraph-${platform}-${arch}${libcSuffix}`;
const nativePkg = Object.keys(optDeps).find((name) => name.includes(platformKey));
if (nativePkg) {
const nativeVersion = optDeps[nativePkg];
console.error(`Installing native package ${nativePkg}@${nativeVersion}...`);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
execFileSync('npm', ['install', `${nativePkg}@${nativeVersion}`, '--no-audit', '--no-fund', '--no-save'], {
cwd: tmpDir,
stdio: 'pipe',
timeout: 120_000,
});
break;
} catch (innerErr) {
if (attempt === maxRetries) throw innerErr;
const delay = attempt * 15_000;
console.error(` Native install attempt ${attempt} failed, retrying in ${delay / 1000}s...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
console.error(`Installed ${nativePkg}@${nativeVersion}`);
} else {
console.error(`No native package found for platform ${platform}-${arch}${libcSuffix}, skipping`);
}
} catch (err) {
console.error(`Warning: failed to install native package: ${err.message}`);
}

const srcDir = path.join(pkgDir, 'src');

if (!fs.existsSync(srcDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true });
throw new Error(`Installed package does not contain src/ at ${srcDir}`);
}

// Resolve the actual version from the installed package
const installedPkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
const resolvedVersion = cliVersion || installedPkg.version;

console.error(`Installed @optave/codegraph@${installedPkg.version}`);
Expand Down
11 changes: 6 additions & 5 deletions tests/integration/build.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,9 @@ describe('three-tier incremental builds', () => {
for (const [name, content] of Object.entries(FIXTURE_FILES)) {
fs.writeFileSync(path.join(incrDir, name), content);
}
// First full build
await buildGraph(incrDir, { skipRegistry: true });
// First full build — disable cfg/dataflow so the no-change rebuild
// test doesn't trigger a pending analysis pass instead of "No changes detected"
await buildGraph(incrDir, { skipRegistry: true, cfg: false, dataflow: false });
incrDbPath = path.join(incrDir, '.codegraph', 'graph.db');
});

Expand All @@ -178,7 +179,7 @@ describe('three-tier incremental builds', () => {
return true;
};
try {
await buildGraph(incrDir, { skipRegistry: true });
await buildGraph(incrDir, { skipRegistry: true, cfg: false, dataflow: false });
} finally {
process.stderr.write = origWrite;
}
Expand Down Expand Up @@ -334,7 +335,7 @@ describe('three-tier incremental builds', () => {
test('rebuild with corrupt journal falls back to Tier 1', async () => {
// Reset utils.js
fs.writeFileSync(path.join(incrDir, 'utils.js'), FIXTURE_FILES['utils.js']);
await buildGraph(incrDir, { skipRegistry: true });
await buildGraph(incrDir, { skipRegistry: true, cfg: false, dataflow: false });

// Corrupt the journal
fs.writeFileSync(
Expand All @@ -350,7 +351,7 @@ describe('three-tier incremental builds', () => {
return true;
};
try {
await buildGraph(incrDir, { skipRegistry: true });
await buildGraph(incrDir, { skipRegistry: true, cfg: false, dataflow: false });
} finally {
process.stderr.write = origWrite;
}
Expand Down
11 changes: 7 additions & 4 deletions tests/parsers/ast-all-langs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -350,10 +350,11 @@ describe.skipIf(!canTestMultiLang)('native AST nodes — multi-language', () =>
const fStr = strings.find((n) => n.name.includes('hello'));
expect(fStr).toBeDefined();
expect(fStr.name.startsWith('f')).toBe(false);
// rb"..." prefix should be stripped
// rb"..." prefix should be stripped — check for multi-char prefix remnant,
// not single 'r' since the content "raw bytes value" starts with 'r' naturally
const rbStr = strings.find((n) => n.name.includes('raw bytes'));
expect(rbStr).toBeDefined();
expect(rbStr.name.startsWith('r')).toBe(false);
expect(rbStr.name).not.toMatch(/^rb/);
expect(rbStr.name.startsWith('b')).toBe(false);
});

Expand Down Expand Up @@ -467,8 +468,10 @@ describe.skipIf(!canTestMultiLang)('native AST nodes — multi-language', () =>
.all();
const rawStr = strings.find((n) => n.name.includes('raw string content'));
expect(rawStr).toBeDefined();
// Name should not contain r, #, or quote prefixes
expect(rawStr.name).not.toMatch(/^[r#"]/);
// Name should not have r# delimiter prefix or quote chars — but the content
// "raw string content" naturally starts with 'r', so check for delimiter patterns
expect(rawStr.name).not.toMatch(/^r#/);
expect(rawStr.name).not.toMatch(/^[#"]/);
});

// ── PHP ──
Expand Down
30 changes: 28 additions & 2 deletions tests/parsers/cfg-all-langs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ class Processor
if items.empty?
return []
end
items.each do |item|
for item in items
puts item
end
items
Expand Down Expand Up @@ -267,6 +267,32 @@ function nativeSupportsCfg() {

const canTestNativeCfg = nativeSupportsCfg();

// The published native binary has a bug in process_for_loop that treats
// iterator-style for-loops as infinite loops (missing branch edges).
// The fix is in cfg.rs but only takes effect after the next binary publish.
// Detect the fix by parsing a for-of loop and checking for branch_true edge
// (bounded loop). The buggy binary only produces fallthrough (infinite loop).
const hasFixedCfg = (() => {
const native = loadNative();
if (!native) return false;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cfg-forin-'));
try {
const src = path.join(tmp, 'src');
fs.mkdirSync(src, { recursive: true });
const fp = path.join(src, 'check.js');
fs.writeFileSync(fp, 'function f(items) { for (const x of items) { console.log(x); } }');
const results = native.parseFiles([fp], tmp);
const cfg = results?.[0]?.definitions?.[0]?.cfg;
if (!cfg?.edges) return false;
// Fixed binary emits branch_true from the loop header; buggy binary does not
return cfg.edges.some((e) => e.kind === 'branch_true');
} catch {
return false;
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
})();

describe.skipIf(!canTestNativeCfg)('native CFG — multi-language', () => {
let tmpDir;
const nativeResults = new Map();
Expand Down Expand Up @@ -362,7 +388,7 @@ describe.skipIf(!canTestNativeCfg)('native CFG — multi-language', () => {

// ─── Parity: native vs WASM CFG ──────────────────────────────────────

describe.skipIf(!canTestNativeCfg)('native vs WASM CFG parity', () => {
describe.skipIf(!canTestNativeCfg || !hasFixedCfg)('native vs WASM CFG parity', () => {
let tmpDir;
const nativeResults = new Map();
let parsers;
Expand Down