diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 8c00979d..d7f6cc6b 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -479,10 +479,7 @@ fn is_eligible_object_literal_declarator(declarator: &Node) -> bool { if declarator.kind() != "variable_declarator" { return false; } let Some(name_n) = declarator.child_by_field_name("name") else { return false }; if name_n.kind() != "identifier" { return false; } - find_parent_of_types(declarator, &[ - "function_declaration", "arrow_function", "function_expression", - "method_definition", "generator_function_declaration", "generator_function", - ]).is_none() + find_parent_of_types(declarator, &VAR_DECL_FN_SCOPE_TYPES).is_none() } /// True when `method_node` (a method_definition) is a shorthand method whose enclosing object @@ -1303,10 +1300,19 @@ fn handle_enum_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { } } +/// Node types marking a function-body scope; declarations inside these are skipped by +/// the top-level-constant/destructuring branches below (parity with TS `FUNCTION_SCOPE_TYPES`). +const VAR_DECL_FN_SCOPE_TYPES: [&str; 6] = [ + "function_declaration", "arrow_function", + "function_expression", "method_definition", + "generator_function_declaration", "generator_function", +]; + fn handle_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { let is_const = node.child(0) .map(|c| node_text(&c, source) == "const") .unwrap_or(false); + let in_function_scope = find_parent_of_types(node, &VAR_DECL_FN_SCOPE_TYPES).is_some(); for i in 0..node.child_count() { let Some(declarator) = node.child(i) else { continue }; if declarator.kind() != "variable_declarator" { continue; } @@ -1314,6 +1320,7 @@ fn handle_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { let value_n = declarator.child_by_field_name("value"); let (Some(name_n), Some(value_n)) = (name_n, value_n) else { continue }; let vt = value_n.kind(); + if vt == "arrow_function" || vt == "function_expression" || vt == "function" || vt == "generator_function" { let children = extract_js_parameters(&value_n, source); symbols.definitions.push(Definition { @@ -1326,13 +1333,7 @@ fn handle_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { cfg: build_function_cfg(&value_n, "javascript", source), children: opt_children(children), }); - } else if is_const && name_n.kind() == "object_pattern" - && find_parent_of_types(node, &[ - "function_declaration", "arrow_function", - "function_expression", "method_definition", - "generator_function_declaration", "generator_function", - ]).is_none() - { + } else if is_const && name_n.kind() == "object_pattern" && !in_function_scope { // Parity with TS query path (extractDestructuredBindingsWalk): // skip destructured const bindings inside function scopes so the // Rust walk path matches FUNCTION_SCOPE_TYPES behaviour. @@ -1362,13 +1363,14 @@ fn handle_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { } } } - } else if is_const && is_js_literal(&value_n) - && find_parent_of_types(node, &[ - "function_declaration", "arrow_function", - "function_expression", "method_definition", - "generator_function_declaration", "generator_function", - ]).is_none() + } else if is_const + && (name_n.kind() == "identifier" || name_n.kind() == "array_pattern") + && !in_function_scope { + // Any other initializer shape becomes a "constant" Definition, regardless of + // complexity (call/member/parenthesized expressions, etc.) — mirroring how + // function declarations are captured regardless of body complexity, and the + // WASM/TS extractor's unconditional identifier + array_pattern branches (#1819). symbols.definitions.push(Definition { name: node_text(&name_n, source).to_string(), kind: "constant".to_string(), @@ -1397,7 +1399,13 @@ fn handle_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { // definitions in the wrong relative position. let var_name = node_text(&name_n, source); extract_object_literal_functions(&value_n, source, var_name, symbols); - } else if name_n.kind() == "identifier" && value_n.kind() == "identifier" { + } + + // pts fn_ref_binding tracking runs independently of the Definition-shape branching + // above (mirrors WASM's collectFnRefBindings, which always runs before any + // Definition-related early return) so `const alias = handler` still seeds a pts + // alias even though `alias` now also gets its own "constant" Definition (#1819). + if name_n.kind() == "identifier" && value_n.kind() == "identifier" { // Phase 8.3: `const alias = handler` — record for pts analysis. // Mirror the JS BUILTIN_GLOBALS guard: skip well-known JS globals so // they are never seeded as pts targets (e.g. `const a = Array`). @@ -1737,8 +1745,8 @@ fn handle_export_declaration(node: &Node, decl: &Node, source: &[u8], symbols: & /// field (handled above); a lexical/variable declaration doesn't, so each /// declarator's value is classified the same way `handle_var_decl` classifies /// it when creating the matching `Definition`: function-valued declarators -/// become kind "function", `const` declarators with a literal/array/object/ -/// new-expression value (per `is_js_literal`) become kind "constant". +/// become kind "function"; any other `const` declarator becomes kind "constant", +/// regardless of initializer complexity (#1819). /// Mirrors the WASM/TS extractor's `collectExportedDeclarations`. /// /// This predicate must stay identical to `handle_var_decl`'s: `insert_nodes.rs` @@ -1770,7 +1778,7 @@ fn collect_exported_var_declarations( kind: "function".to_string(), line, }); - } else if is_const && is_js_literal(&value_n) { + } else if is_const { symbols.exports.push(ExportInfo { name: node_text(&name_n, source).to_string(), kind: "constant".to_string(), @@ -2176,14 +2184,6 @@ fn extract_ts_enum_members(node: &Node, source: &[u8]) -> Vec { members } -fn is_js_literal(node: &Node) -> bool { - matches!(node.kind(), - "number" | "string" | "true" | "false" | "null" | "undefined" - | "template_string" | "regex" | "array" | "object" - | "unary_expression" | "binary_expression" | "new_expression" - ) -} - // ── Existing helpers ──────────────────────────────────────────────────────── fn extract_interface_methods( @@ -4131,6 +4131,78 @@ mod tests { assert_eq!(s.definitions[0].name, "main"); } + // ── #1819: top-level const with a non-"literal-shaped" initializer ──────── + + #[test] + fn extracts_const_with_member_expression_initializer_as_constant() { + // Repro from #1819: a parenthesized member-expression initializer + // (`(...).version`) was not one of the recognized "literal" shapes, so + // the whole declaration was silently dropped — not just unexported, + // absent from `definitions` entirely. + let s = parse_js( + "const CODEGRAPH_VERSION = (JSON.parse(readFileSync(pkgPath, 'utf-8'))).version;", + ); + let def = s + .definitions + .iter() + .find(|d| d.name == "CODEGRAPH_VERSION") + .unwrap_or_else(|| panic!("CODEGRAPH_VERSION should be extracted as a definition")); + assert_eq!(def.kind, "constant"); + } + + #[test] + fn extracts_const_with_call_expression_initializer_as_constant() { + let s = parse_js("const config = loadConfig();"); + let def = s + .definitions + .iter() + .find(|d| d.name == "config") + .unwrap_or_else(|| panic!("config should be extracted as a definition")); + assert_eq!(def.kind, "constant"); + } + + #[test] + fn exports_const_with_call_expression_initializer() { + let s = parse_js("export const config = loadConfig();"); + assert!( + s.exports.iter().any(|e| e.name == "config" && e.kind == "constant"), + "config should be listed as an exported constant; got: {:?}", + s.exports + ); + } + + #[test] + fn extracts_const_array_pattern_with_call_expression_initializer() { + // Parity with the identifier case above: array-pattern names must also + // be discoverable regardless of initializer complexity. + let s = parse_js("const [a, b] = computePair();"); + let def = s + .definitions + .iter() + .find(|d| d.name == "[a, b]") + .unwrap_or_else(|| panic!("[a, b] should be extracted as a definition")); + assert_eq!(def.kind, "constant"); + } + + #[test] + fn const_alias_gets_both_definition_and_fn_ref_binding() { + // The new "constant" Definition for an identifier-aliased const must not + // come at the expense of the existing pts fn_ref_binding tracking — the + // two concerns are independent (mirrors the WASM/TS extractor's + // decoupled fnRefBindings pass). + let s = parse_js("const alias = handler;"); + assert!( + s.definitions.iter().any(|d| d.name == "alias" && d.kind == "constant"), + "alias should be extracted as a constant definition; got: {:?}", + s.definitions + ); + assert!( + s.fn_ref_bindings.iter().any(|b| b.lhs == "alias" && b.rhs == "handler"), + "alias -> handler fn_ref_binding should still be recorded; got: {:?}", + s.fn_ref_bindings + ); + } + // ── AST node extraction tests ──────────────────────────────────────────── #[test] diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index eb7004b6..23136156 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -208,11 +208,13 @@ const EXPORT_DECL_KIND: Record = { * Named function/class/interface/type declarations carry their own `name` * field. `export const/let/var …` has no such field — each declarator's value * is classified the same way `handleVariableDeclarator` classifies it when - * building the matching Definition (function-valued → kind 'function', - * literal/array/object/new-expression-valued `const` → kind 'constant'). - * This predicate must stay identical to the Definition-building one: the - * exported=1 UPDATE it feeds matches DB rows by (name, kind, file, line), so - * a mismatched kind silently no-ops instead of marking the symbol exported (#1728). + * building the matching Definition (function-valued → kind 'function'; any + * other `const` initializer shape → kind 'constant', regardless of complexity — + * mirroring how function declarations are captured regardless of body + * complexity, #1819). This predicate must stay identical to the + * Definition-building one: the exported=1 UPDATE it feeds matches DB rows by + * (name, kind, file, line), so a mismatched kind silently no-ops instead of + * marking the symbol exported (#1728). */ function collectExportedDeclarations( decl: TreeSitterNode, @@ -241,7 +243,7 @@ function collectExportedDeclarations( valType === 'generator_function' ) { exps.push({ name: nameN.text, kind: 'function', line: exportLine }); - } else if (isConst && isConstantValue(valueN)) { + } else if (isConst) { exps.push({ name: nameN.text, kind: 'constant', line: exportLine }); } } @@ -737,21 +739,22 @@ function extractConstDeclarators(declNode: TreeSitterNode, definitions: Definiti valType === 'generator_function' ) continue; - if (isConstantValue(valueN)) { - definitions.push({ - name: nameN.text, - kind: 'constant', - line: nodeStartLine(declNode), - endLine: nodeEndLine(declNode), - }); - // Phase 8.3f: extract function/arrow properties from object literals. - // Scope guard: extractConstDeclarators is only called from extractConstantsWalk, which - // already skips const declarations inside function scopes (line ~412). So these definitions - // are always top-level. Any new call site must add a hasFunctionScopeAncestor guard - // (the walk path at handleVariableDecl does this). - if (valueN.type === 'object') { - extractObjectLiteralFunctions(valueN, nameN.text, definitions); - } + // Any other initializer shape becomes a 'constant' Definition, regardless of + // complexity (call/member/parenthesized expressions, etc.) — mirroring how + // function declarations are captured regardless of body complexity (#1819). + definitions.push({ + name: nameN.text, + kind: 'constant', + line: nodeStartLine(declNode), + endLine: nodeEndLine(declNode), + }); + // Phase 8.3f: extract function/arrow properties from object literals. + // Scope guard: extractConstDeclarators is only called from extractConstantsWalk, which + // already skips const declarations inside function scopes (line ~412). So these definitions + // are always top-level. Any new call site must add a hasFunctionScopeAncestor guard + // (the walk path at handleVariableDecl does this). + if (valueN.type === 'object') { + extractObjectLiteralFunctions(valueN, nameN.text, definitions); } } } @@ -1234,12 +1237,10 @@ function handleVariableDeclarator( valType === 'generator_function' ) { handleVarFnAssignment(node, nameN, valueN, ctx); - } else if ( - isConst && - nameN.type === 'identifier' && - isConstantValue(valueN) && - !hasFunctionScopeAncestor(node) - ) { + } else if (isConst && nameN.type === 'identifier' && !hasFunctionScopeAncestor(node)) { + // Any other initializer shape becomes a 'constant' Definition, regardless of + // complexity (call/member/parenthesized expressions, etc.) — mirroring how + // function declarations are captured regardless of body complexity (#1819). handleConstIdentifierAssignment(node, nameN, valueN, ctx); } else if ( !isConst && @@ -1705,26 +1706,6 @@ function extractVisibility(node: TreeSitterNode): 'public' | 'private' | 'protec return undefined; } -function isConstantValue(valueNode: TreeSitterNode): boolean { - if (!valueNode) return false; - const t = valueNode.type; - return ( - t === 'number' || - t === 'string' || - t === 'template_string' || - t === 'true' || - t === 'false' || - t === 'null' || - t === 'undefined' || - t === 'array' || - t === 'object' || - t === 'regex' || - t === 'unary_expression' || - t === 'binary_expression' || - t === 'new_expression' - ); -} - // ── Shared helpers ────────────────────────────────────────────────────────── function extractInterfaceMethods( diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index c45b9502..26073344 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -2017,6 +2017,61 @@ describe('JavaScript parser', () => { }); }); + describe('top-level const with a non-"literal-shaped" initializer (#1819)', () => { + it('extracts a const with a parenthesized member-expression initializer as a definition (repro)', () => { + // Repro from #1819: `(...).version` isn't one of the recognized "literal" + // shapes, so the whole declaration was previously dropped — not just + // unexported, absent from `definitions` entirely. + const symbols = parseJS( + `export const CODEGRAPH_VERSION = (\n JSON.parse(readFileSync(pkgPath, 'utf-8'))\n).version;`, + ); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'CODEGRAPH_VERSION', kind: 'constant' }), + ); + expect(symbols.exports).toContainEqual( + expect.objectContaining({ name: 'CODEGRAPH_VERSION', kind: 'constant' }), + ); + }); + + it('extracts a const with a call-expression initializer as a definition', () => { + const symbols = parseJS(`const config = loadConfig();`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'config', kind: 'constant' }), + ); + }); + + it('extracts an exported const with a call-expression initializer', () => { + const symbols = parseJS(`export const config = loadConfig();`); + expect(symbols.exports).toContainEqual( + expect.objectContaining({ name: 'config', kind: 'constant' }), + ); + }); + + it('extracts a const with a bare identifier initializer as a definition', () => { + const symbols = parseJS(`const alias = handler;`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'alias', kind: 'constant' }), + ); + // The new Definition must not come at the expense of the existing pts + // fnRefBindings tracking (they're independent passes). + expect(symbols.fnRefBindings).toContainEqual( + expect.objectContaining({ lhs: 'alias', rhs: 'handler' }), + ); + }); + + it('still skips a non-top-level const with a non-literal initializer', () => { + const symbols = parseJS(`function f() { const x = compute(); }`); + expect(symbols.definitions.some((d) => d.name === 'x')).toBe(false); + }); + + it('extracts a const array pattern with a call-expression initializer (parity with identifier case)', () => { + const symbols = parseJS(`const [a, b] = computePair();`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: '[a, b]', kind: 'constant' }), + ); + }); + }); + describe('interface member kind labeling (#1809)', () => { function parseTS(code) { const parser = parsers.get('typescript');