Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3841317
fix: isolate benchmark engine/model failures with try/catch (#408)
carlos-alm Mar 11, 2026
583d995
fix: dispose WASM tree-sitter parsers and trees to prevent segfaults …
carlos-alm Mar 11, 2026
d989e6e
fix: track dynamic import() expressions in native engine (#410)
carlos-alm Mar 11, 2026
9fc84de
fix: report correct native engine version from package.json (#411)
carlos-alm Mar 11, 2026
7fb4afa
fix: address Greptile review feedback on #418
carlos-alm Mar 11, 2026
9844b2e
Merge branch 'main' into fix/issues-408-411
carlos-alm Mar 11, 2026
a55717a
fix: address review feedback on #408–#411
carlos-alm Mar 11, 2026
2fd67f0
Merge remote-tracking branch 'origin/main' into fix/issues-408-411
carlos-alm Mar 11, 2026
40abead
Merge branch 'fix/issues-408-411' of https://github.com/optave/codegr…
carlos-alm Mar 11, 2026
3f4adca
Merge branch 'main' into fix/issues-408-411
carlos-alm Mar 11, 2026
fe6d978
fix: deduplicate native.js import in info command
carlos-alm Mar 11, 2026
381548e
Merge branch 'fix/issues-408-411' of https://github.com/optave/codegr…
carlos-alm Mar 11, 2026
d22a6ac
fix: address round-2 review feedback
carlos-alm Mar 11, 2026
1582b65
fix: handle object_assignment_pattern in dynamic import destructuring
carlos-alm Mar 11, 2026
20203c2
fix: handle array_pattern bindings in dynamic import destructuring
carlos-alm Mar 11, 2026
bbfd0c7
fix: use null-safe error message pattern in embedding-benchmark
carlos-alm Mar 12, 2026
32b0762
Merge branch 'main' into fix/issues-408-411
carlos-alm Mar 12, 2026
7d55362
fix: dispose cached Language objects in disposeParsers()
carlos-alm Mar 12, 2026
de32c38
fix: handle rest_pattern and nested destructuring in dynamic imports
carlos-alm Mar 12, 2026
53ce6bd
fix: handle rest_pattern in array_pattern for dynamic imports
carlos-alm Mar 12, 2026
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
126 changes: 125 additions & 1 deletion crates/codegraph-core/src/extractors/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,23 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) {

"call_expression" => {
if let Some(fn_node) = node.child_by_field_name("function") {
if let Some(call_info) = extract_call_info(&fn_node, node, source) {
// Detect dynamic import() expressions
if fn_node.kind() == "import" {
if let Some(args) = node.child_by_field_name("arguments")
.or_else(|| find_child(node, "arguments"))
{
if let Some(str_node) = find_child(&args, "string")
.or_else(|| find_child(&args, "template_string"))
{
let mod_path = node_text(&str_node, source)
.replace(&['\'', '"', '`'][..], "");
let names = extract_dynamic_import_names(node, source);
let mut imp = Import::new(mod_path, names, start_line(node));
imp.dynamic_import = Some(true);
symbols.imports.push(imp);
}
}
} else if let Some(call_info) = extract_call_info(&fn_node, node, source) {
symbols.calls.push(call_info);
}
}
Expand Down Expand Up @@ -1000,6 +1016,96 @@ fn find_parent_class<'a>(node: &Node<'a>, source: &[u8]) -> Option<String> {
None
}

/// Extract named bindings from a dynamic `import()` call expression.
/// Handles: `const { a, b } = await import(...)` and `const mod = await import(...)`
fn extract_dynamic_import_names(call_node: &Node, source: &[u8]) -> Vec<String> {
// Walk up: call_expression → await_expression? → variable_declarator
let mut current = call_node.parent();
if let Some(parent) = current {
if parent.kind() == "await_expression" {
current = parent.parent();
}
}
let declarator = match current {
Some(n) if n.kind() == "variable_declarator" => n,
_ => return Vec::new(),
};
let name_node = match declarator.child_by_field_name("name") {
Some(n) => n,
None => return Vec::new(),
};
match name_node.kind() {
// const { a, b } = await import(...)
"object_pattern" => {
let mut names = Vec::new();
Comment on lines +1033 to +1040
Copy link
Contributor

Choose a reason for hiding this comment

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

array_pattern bindings silently dropped from dynamic imports

The extract_dynamic_import_names function handles object_pattern destructuring and plain identifier bindings, but not array_pattern. In JavaScript, array destructuring from a dynamic import is valid:

const [first, second] = await import('./list.js');

Tree-sitter parses the name node as an array_pattern here. The current match name_node.kind() falls through to _ => Vec::new(), so the import edge is emitted (the path is resolved correctly) but zero bindings are captured. Consumers that rely on names to resolve which exports are used will silently miss these.

Consider adding an "array_pattern" arm that iterates children and collects identifier nodes, similar to the object_pattern arm, or at a minimum document that array-pattern bindings are intentionally not supported yet.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 20203c2 — added an array_pattern arm to extract_dynamic_import_names that iterates children and collects identifier and assignment_pattern bindings.

for i in 0..name_node.child_count() {
if let Some(child) = name_node.child(i) {
if child.kind() == "shorthand_property_identifier_pattern"
|| child.kind() == "shorthand_property_identifier"
{
names.push(node_text(&child, source).to_string());
} else if child.kind() == "pair_pattern" || child.kind() == "pair" {
if let Some(val) = child.child_by_field_name("value") {
// Handle `{ foo: bar = 'default' }` — extract the left-hand binding
let binding = if val.kind() == "assignment_pattern" {
val.child_by_field_name("left").unwrap_or(val)
} else if val.kind() == "identifier" {
val
} else {
// Nested pattern (e.g. `{ foo: { bar } }`) — skip;
// full nested support requires recursive extraction.
continue;
};
names.push(node_text(&binding, source).to_string());
} else if let Some(key) = child.child_by_field_name("key") {
names.push(node_text(&key, source).to_string());
}
} else if child.kind() == "object_assignment_pattern" {
// Handle `{ a = 'default' }` — extract the left-hand binding
if let Some(left) = child.child_by_field_name("left") {
names.push(node_text(&left, source).to_string());
}
} else if child.kind() == "rest_pattern" || child.kind() == "rest_element" {
// Handle `{ a, ...rest }` — extract the identifier inside the spread
if let Some(inner) = child.child(0) {
if inner.kind() == "identifier" {
names.push(node_text(&inner, source).to_string());
}
}
}
}
}
names
}
// const mod = await import(...)
"identifier" => vec![node_text(&name_node, source).to_string()],
// const [first, second] = await import(...)
"array_pattern" => {
let mut names = Vec::new();
for i in 0..name_node.child_count() {
if let Some(child) = name_node.child(i) {
if child.kind() == "identifier" {
names.push(node_text(&child, source).to_string());
} else if child.kind() == "assignment_pattern" {
if let Some(left) = child.child_by_field_name("left") {
names.push(node_text(&left, source).to_string());
}
} else if child.kind() == "rest_pattern" || child.kind() == "rest_element" {
// Handle `[a, ...rest]` — extract the identifier inside the spread
if let Some(inner) = child.child(0) {
if inner.kind() == "identifier" {
names.push(node_text(&inner, source).to_string());
}
}
}
}
}
names
}
_ => Vec::new(),
}
}

fn extract_import_names(node: &Node, source: &[u8]) -> Vec<String> {
let mut names = Vec::new();
scan_import_names(node, source, &mut names);
Expand Down Expand Up @@ -1334,4 +1440,22 @@ mod tests {
assert_eq!(str_nodes.len(), 1);
assert!(str_nodes[0].name.contains("hello template"));
}

#[test]
fn finds_dynamic_import() {
let s = parse_js("const mod = import('./foo.js');");
let dyn_imports: Vec<_> = s.imports.iter().filter(|i| i.dynamic_import == Some(true)).collect();
assert_eq!(dyn_imports.len(), 1);
assert_eq!(dyn_imports[0].source, "./foo.js");
}

#[test]
fn finds_dynamic_import_with_destructuring() {
let s = parse_js("const { a, b } = await import('./bar.js');");
let dyn_imports: Vec<_> = s.imports.iter().filter(|i| i.dynamic_import == Some(true)).collect();
assert_eq!(dyn_imports.len(), 1);
assert_eq!(dyn_imports[0].source, "./bar.js");
assert!(dyn_imports[0].names.contains(&"a".to_string()));
assert!(dyn_imports[0].names.contains(&"b".to_string()));
}
}
3 changes: 3 additions & 0 deletions crates/codegraph-core/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ pub struct Import {
pub ruby_require: Option<bool>,
#[napi(js_name = "phpUse")]
pub php_use: Option<bool>,
#[napi(js_name = "dynamicImport")]
pub dynamic_import: Option<bool>,
}

impl Import {
Expand All @@ -154,6 +156,7 @@ impl Import {
csharp_using: None,
ruby_require: None,
php_use: None,
dynamic_import: None,
}
}
}
Expand Down
17 changes: 15 additions & 2 deletions scripts/benchmark.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,22 @@ if (!hasWasm && !hasNative) {

let wasm = null;
if (hasWasm) {
wasm = await benchmarkEngine('wasm');
try {
wasm = await benchmarkEngine('wasm');
} catch (err) {
console.error(`WASM benchmark failed: ${err?.message ?? String(err)}`);
}
} else {
console.error('WASM grammars not built — skipping WASM benchmark');
}

let native = null;
if (hasNative) {
native = await benchmarkEngine('native');
try {
native = await benchmarkEngine('native');
} catch (err) {
console.error(`Native benchmark failed: ${err?.message ?? String(err)}`);
}
} else {
console.error('Native engine not available — skipping native benchmark');
}
Expand All @@ -195,6 +203,11 @@ if (hasNative) {
console.log = origLog;

const primary = wasm || native;
if (!primary) {
console.error('Error: Both engines failed. No results to report.');
cleanup();
process.exit(1);
}
const result = {
version,
date: new Date().toISOString().slice(0, 10),
Expand Down
9 changes: 7 additions & 2 deletions scripts/embedding-benchmark.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,14 @@ for (const key of modelKeys) {
` Hit@1=${r.hits1}/${r.total} Hit@3=${r.hits3}/${r.total} Hit@5=${r.hits5}/${r.total} misses=${r.misses}`,
);
} catch (err) {
console.error(` FAILED: ${err.message}`);
console.error(` FAILED: ${err?.message ?? String(err)}`);
} finally {
try {
await disposeModel();
} catch (disposeErr) {
console.error(` disposeModel failed: ${disposeErr?.message ?? String(disposeErr)}`);
}
}
await disposeModel();
}

// Restore console.log for JSON output
Expand Down
7 changes: 6 additions & 1 deletion src/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -1354,8 +1354,13 @@ export async function buildGraph(rootDir, opts = {}) {

_t.finalize0 = performance.now();

// Release any remaining cached WASM trees for GC
// Release any remaining cached WASM trees — call .delete() to free WASM memory
for (const [, symbols] of allSymbols) {
if (symbols._tree && typeof symbols._tree.delete === 'function') {
try {
symbols._tree.delete();
} catch {}
}
symbols._tree = null;
symbols._langId = null;
}
Expand Down
14 changes: 11 additions & 3 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -1390,7 +1390,7 @@ program
.command('info')
.description('Show codegraph engine info and diagnostics')
.action(async () => {
const { isNativeAvailable, loadNative } = await import('./native.js');
const { getNativePackageVersion, isNativeAvailable, loadNative } = await import('./native.js');
const { getActiveEngine } = await import('./parser.js');

const engine = program.opts().engine;
Expand All @@ -1405,9 +1405,17 @@ program
console.log(` Native engine : ${nativeAvailable ? 'available' : 'unavailable'}`);
if (nativeAvailable) {
const native = loadNative();
const nativeVersion =
const binaryVersion =
typeof native.engineVersion === 'function' ? native.engineVersion() : 'unknown';
console.log(` Native version: ${nativeVersion}`);
const pkgVersion = getNativePackageVersion();
const knownBinaryVersion = binaryVersion !== 'unknown' ? binaryVersion : null;
if (pkgVersion && knownBinaryVersion && pkgVersion !== knownBinaryVersion) {
console.log(
` Native version: ${pkgVersion} (binary reports ${knownBinaryVersion} — stale)`,
);
} else {
console.log(` Native version: ${pkgVersion ?? binaryVersion}`);
}
}
console.log(` Engine flag : --engine ${engine}`);
console.log(` Active engine : ${activeName}${activeVersion ? ` (v${activeVersion})` : ''}`);
Expand Down
8 changes: 7 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,13 @@ export { matchOwners, ownersData, ownersForFiles, parseCodeowners } from './owne
// Pagination utilities
export { MCP_DEFAULTS, MCP_MAX_LIMIT, paginate, paginateResult, printNdjson } from './paginate.js';
// Unified parser API
export { getActiveEngine, isWasmAvailable, parseFileAuto, parseFilesAuto } from './parser.js';
export {
disposeParsers,
getActiveEngine,
isWasmAvailable,
parseFileAuto,
parseFilesAuto,
} from './parser.js';
// Query functions (data-returning)
export {
ALL_SYMBOL_KINDS,
Expand Down
40 changes: 31 additions & 9 deletions src/native.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ import os from 'node:os';

let _cached; // undefined = not yet tried, null = failed, object = module
let _loadError = null;
const _require = createRequire(import.meta.url);

/**
* Detect whether the current Linux environment uses glibc or musl.
* Returns 'gnu' for glibc, 'musl' for musl, 'gnu' as fallback.
*/
function detectLibc() {
try {
const { readdirSync } = require('node:fs');
const { readdirSync } = _require('node:fs');
const files = readdirSync('/lib');
if (files.some((f) => f.startsWith('ld-musl-') && f.endsWith('.so.1'))) {
return 'musl';
Expand All @@ -38,28 +39,34 @@ const PLATFORM_PACKAGES = {
'win32-x64': '@optave/codegraph-win32-x64-msvc',
};

/**
* Resolve the platform-specific npm package name for the native addon.
* Returns null if the current platform is not supported.
*/
function resolvePlatformPackage() {
const platform = os.platform();
const arch = os.arch();
const key = platform === 'linux' ? `${platform}-${arch}-${detectLibc()}` : `${platform}-${arch}`;
return PLATFORM_PACKAGES[key] || null;
}

/**
* Try to load the native napi addon.
* Returns the module on success, null on failure.
*/
export function loadNative() {
if (_cached !== undefined) return _cached;

const require = createRequire(import.meta.url);

const platform = os.platform();
const arch = os.arch();
const key = platform === 'linux' ? `${platform}-${arch}-${detectLibc()}` : `${platform}-${arch}`;
const pkg = PLATFORM_PACKAGES[key];
const pkg = resolvePlatformPackage();
if (pkg) {
try {
_cached = require(pkg);
_cached = _require(pkg);
return _cached;
} catch (err) {
_loadError = err;
}
} else {
_loadError = new Error(`Unsupported platform: ${key}`);
_loadError = new Error(`Unsupported platform: ${os.platform()}-${os.arch()}`);
}

_cached = null;
Expand All @@ -73,6 +80,21 @@ export function isNativeAvailable() {
return loadNative() !== null;
}

/**
* Read the version from the platform-specific npm package.json.
* Returns null if the package is not installed or has no version.
*/
export function getNativePackageVersion() {
const pkg = resolvePlatformPackage();
if (!pkg) return null;
try {
const pkgJson = _require(`${pkg}/package.json`);
return pkgJson.version || null;
} catch {
return null;
}
}

/**
* Return the native module or throw if not available.
*/
Expand Down
Loading
Loading