From fa25883ab0ee0e26e5f6d86b59f25e0e3490c37f Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sun, 27 Sep 2026 13:46:34 +0000 Subject: [PATCH] fix(cpp): resolve local constructors and macro visibility (#1839) (#2035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cpp): macro invocations are not calls; local objects call their constructor A function-like macro invocation (`TRACE_POINT(1)`) parses as a call, and the resolver bound it to a same-named free function in another translation unit — a caller and a callee that never existed (#1838). And local object initialization (`Widget w;` / `Widget w(1);` / `Widget w{1};`) reached the class node instead of a constructor, or nothing at all for the default form (#1839). Extraction (wasm walker and the Rust kernel, in parity): - every `preproc_function_def` becomes a `constant` node whose signature is the directive, so the index knows which names are macros; - a C++ declaration emits one `calls` ref per constructed object, shaped `ns::T::T/`; pointer, reference and function declarators, `extern`, and array element braces construct nothing; the #1035 `instantiates` ref is unchanged; - constructors carry their parameter list as the signature. Resolution: - `cpp-macro-visibility.ts` walks the translation unit (root file plus in-repo includes, each file once) over per-file summaries of function-like defines, undefs and includes, evaluating what each file can decide itself (literals, its own defines, the include-guard idiom). A call whose name is definitely a macro at that point, or a name that only macros bear, resolves to nothing. A macro that exists in one build configuration only (CMSIS's `__DSB()` under the ARM compilers, a target's HAL compat header) keeps the call to the function the other configuration compiles; a wrapper macro that calls its own name is how that function gets called and hides nothing. - `cpp-constructor.ts` resolves the constructor refs: the type in the lexical namespaces of the call site, then the single overload whose parameter count admits the arguments; two admitting overloads, an initializer_list overload, or an aggregate yield no edge. - a `#define` constant is never a `calls` target and does not count toward the same-name ceiling. Measured on a betaflight tree (4,017 files, wasm arm): 153 `calls` edges lost — all fabricated (`MIN`/`MAX` bound to a CLI enum member, macros misparsed as functions, a cross-vendor `SWAPBYTE`) — and 34 constructor edges gained; indexing 34s vs 30s. Fixes #1838 Fixes #1839 Co-Authored-By: Claude Fable 5.1 * fix(cpp): resolve local constructors and macro visibility (#1839) Local construction lacked constructor calls, while macro invocations could bind unrelated functions. Resolve constructors using lexical types and overload signatures, preserving separately declared defaults and array element construction. Evaluate macro directives in translation-unit order, respecting reinclusion, guards and pragma once. Mirror extraction in Rust and WASM, preserve type dependencies, and distinguish them in CLI output. Co-authored-by: danusha2345 Co-authored-by: Claude Fable 5.1 Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: danusha2345 Co-authored-by: Claude Fable 5.1 --- .../cpp-macro-and-constructor-calls.test.ts | 505 ++++++++++++++++++ __tests__/extraction.test.ts | 7 + .../kernel-parity/torture-macros-ctors.cpp | 66 +++ .../fixtures/kernel-parity/torture-macros.c | 15 + __tests__/kernel-ccpp-parity.test.ts | 57 ++ codegraph-kernel/src/ccpp/mod.rs | 155 +++++- src/bin/codegraph.ts | 14 +- src/extraction/languages/c-cpp.ts | 24 +- src/extraction/tree-sitter.ts | 146 ++++- src/resolution/cpp-constructor.ts | 126 +++++ src/resolution/cpp-macro-visibility.ts | 326 +++++++++++ src/resolution/index.ts | 19 + src/resolution/name-matcher.ts | 7 +- src/resolution/types.ts | 9 + 14 files changed, 1431 insertions(+), 45 deletions(-) create mode 100644 __tests__/cpp-macro-and-constructor-calls.test.ts create mode 100644 __tests__/fixtures/kernel-parity/torture-macros-ctors.cpp create mode 100644 __tests__/fixtures/kernel-parity/torture-macros.c create mode 100644 src/resolution/cpp-constructor.ts create mode 100644 src/resolution/cpp-macro-visibility.ts diff --git a/__tests__/cpp-macro-and-constructor-calls.test.ts b/__tests__/cpp-macro-and-constructor-calls.test.ts new file mode 100644 index 000000000..803b7c24d --- /dev/null +++ b/__tests__/cpp-macro-and-constructor-calls.test.ts @@ -0,0 +1,505 @@ +/** + * C/C++ call-graph fidelity for two call-less shapes (#1838, #1839): + * + * - a function-like macro invocation (`TRACE_POINT(1)`) whose macro is + * visible in the translation unit — its own `#define`s or an in-repo + * include — never binds to a same-named free function elsewhere, and the + * macro itself is never a callee; + * - local object initialization (`T obj;` / `T obj(args)` / `T obj{args}`) + * calls the constructor method, chosen in the lexical namespace of the + * site and, among overloads, by arity when exactly one admits the + * argument count — never the class node, and nothing for an aggregate. + * + * Both issues' reproductions are indexed verbatim; the negative controls pin + * that real calls and declarations that construct nothing are untouched. + */ +import { execFileSync } from 'child_process'; +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { ToolHandler } from '../src/mcp/tools'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import type { Node } from '../src/types'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-calls-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +function fn(cg: CodeGraph, name: string): Node { + const node = cg.getNodesByKind('function').find((n) => n.name === name); + if (!node) throw new Error(`no function ${name}`); + return node; +} + +/** `calls` callees of a function, as `kind qualifiedName (file)`. */ +function calls(cg: CodeGraph, caller: string): string[] { + return cg + .getCallees(fn(cg, caller).id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => `${r.node.kind} ${r.node.qualifiedName} (${r.node.filePath})`) + .sort(); +} + +describe('#1838 — a macro invocation is not a call to a same-named function', () => { + it('reproduction: the macro from an included header does not bind to the decoy function', async () => { + const cg = await indexed({ + 'marker.hpp': '#define TRACE_POINT(value) ((void)(value))\n', + 'exercise.cpp': '#include "marker.hpp"\n\nvoid exercise() {\n TRACE_POINT(1);\n}\n', + 'macro_decoy.cpp': 'void TRACE_POINT(int value) {}\n', + }); + try { + expect(calls(cg, 'exercise')).toEqual([]); + const decoy = fn(cg, 'TRACE_POINT'); + expect(cg.getCallers(decoy.id)).toEqual([]); + } finally { + cg.close(); + } + }); + + it.each(['c', 'cpp'] as const)( + 'suppresses macros visible through nested includes, a sibling header, a local #define; honors #undef (%s)', + async (language) => { + const cg = await indexed({ + 'src/pg/pinio.h': '#define HEADER_TRACE(v) ((void)(v))\n', + 'src/drivers/pinio.h': '// unrelated header with the same basename\n', + [`src/pg/pinio.${language}`]: '#include "pinio.h"\nvoid sibling_header_use() { HEADER_TRACE(1); }\n', + 'inner.h': '#define TRACE_POINT(value) ((void)(value))\n', + 'outer.h': '#include "inner.h"\n', + [`exercise.${language}`]: [ + '#include "outer.h"', + 'void macro_use() { TRACE_POINT(1); }', + 'void local_macro() {', + '#define INNER_TRACE(v) ((void)(v))', + 'INNER_TRACE(1);', + '}', + '#undef TRACE_POINT', + 'void after_undef() { TRACE_POINT(1); }', + '', + ].join('\n'), + [`decoy.${language}`]: [ + 'void HEADER_TRACE(int value) {}', + 'void INNER_TRACE(int value) {}', + 'void TRACE_POINT(int value) {}', + 'void unrelated_use() { TRACE_POINT(1); }', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'macro_use')).toEqual([]); + expect(calls(cg, 'sibling_header_use')).toEqual([]); + expect(calls(cg, 'local_macro')).toEqual([]); + // After `#undef`, and in a file that never sees the macro, the call is real. + expect(calls(cg, 'after_undef')).toEqual([`function TRACE_POINT (decoy.${language})`]); + expect(calls(cg, 'unrelated_use')).toEqual([`function TRACE_POINT (decoy.${language})`]); + } finally { + cg.close(); + } + } + ); + + it.each(['c', 'cpp'] as const)('replays unguarded includes and respects guards, pragma once and changed flags (%s)', async (language) => { + const cg = await indexed({ + 'unguarded.h': '#define TRACE(v) ((void)(v))\n', + 'guarded.h': '#ifndef GUARDED_H\n#define GUARDED_H\n#define GUARDED(v) ((void)(v))\n#endif\n', + 'once.h': '#pragma once\n#define ONCE(v) ((void)(v))\n', + 'conditional.h': '#if ENABLE_TRACE\n#define CONDITIONAL(v) ((void)(v))\n#endif\n', + [`unit.${language}`]: [ + '#include "unguarded.h"', '#undef TRACE', + 'void between() { TRACE(1); }', + '#include "unguarded.h"', 'void repeated() { TRACE(1); }', + '#include "guarded.h"', '#undef GUARDED', '#include "guarded.h"', + 'void guarded() { GUARDED(1); }', + '#undef GUARDED_H', '#include "guarded.h"', 'void reset_guard() { GUARDED(1); }', + '#include "once.h"', '#undef ONCE', '#include "once.h"', 'void once() { ONCE(1); }', + '#define ENABLE_TRACE 0', '#include "conditional.h"', + '#undef ENABLE_TRACE', '#define ENABLE_TRACE 1', '#include "conditional.h"', + 'void changed_flag() { CONDITIONAL(1); }', '', + ].join('\n'), + [`decoy.${language}`]: 'void TRACE(int x) {}\nvoid GUARDED(int x) {}\nvoid ONCE(int x) {}\nvoid CONDITIONAL(int x) {}\n', + }); + try { + expect(calls(cg, 'between')).toEqual([`function TRACE (decoy.${language})`]); + expect(calls(cg, 'repeated')).toEqual([]); + expect(calls(cg, 'guarded')).toEqual([`function GUARDED (decoy.${language})`]); + expect(calls(cg, 'reset_guard')).toEqual([]); + expect(calls(cg, 'once')).toEqual([`function ONCE (decoy.${language})`]); + expect(calls(cg, 'changed_flag')).toEqual([]); + } finally { cg.close(); } + }); + + it('a diamond-shaped, cyclic include graph still finds the macro on an unconditional path', async () => { + // top.h includes left.h and right.h; both include shared.h (no include + // guard), which includes top.h again. The active include stack breaks the + // cycle while shared.h is replayed on the unconditional path. + const cg = await indexed({ + 'top.h': '#include "left.h"\n#include "right.h"\n', + 'left.h': '#ifdef USE_LEFT\n#include "shared.h"\n#endif\n', + 'right.h': '#include "shared.h"\n', + 'shared.h': '#include "top.h"\n#define SHARED_TRACE(v) ((void)(v))\n', + 'unit.cpp': '#include "top.h"\nvoid unit() { SHARED_TRACE(1); }\n', + 'decoy.cpp': 'void SHARED_TRACE(int value) {}\n', + }); + try { + expect(calls(cg, 'unit')).toEqual([]); + } finally { + cg.close(); + } + }); + + it('control: a #define inside a block comment, string or raw literal is not a macro', async () => { + const cg = await indexed({ + 'doc.hpp': '/*\n * Example:\n * #define helper(x) ((x) + 1)\n */\nconst char *usage = "/* #define helper(x) */";\nconst char *raw = R"doc(\n#define helper(x) ((x) + 9)\n)doc";\n', + 'lib.cpp': '#include "doc.hpp"\nint helper(int x) { return x + 1; }\nint run() { return helper(1); }\n', + 'other.cpp': '#define helper(x) ((x) + 2)\n', + }); + try { + expect(calls(cg, 'run')).toEqual(['function helper (lib.cpp)']); + } finally { + cg.close(); + } + }); + + it('control: a wrapper macro whose body calls the same-named function keeps that call', async () => { + const cg = await indexed({ + 'vec.c': [ + 'static void vec_splice(char **data, int start, int count) {}', + '#define vec_splice(v, start, count)\\', + ' ( vec_splice((char **)(v), start, count),\\', + ' (v)->length -= (count) )', + 'struct buf { char *data; int length; };', + 'void flush(struct buf *b) { vec_splice(b, 0, 1); }', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'flush')).toEqual(['function vec_splice (vec.c)']); + } finally { + cg.close(); + } + }); + + it('control: a macro defined only in an unrelated file neither suppresses nor receives a real call', async () => { + const cg = await indexed({ + 'unrelated.hpp': '#define helper(x) ((x) + 1)\n', + 'lib.cpp': 'int helper(int x) { return x + 1; }\n', + 'main.cpp': 'int helper(int x);\nint run() { return helper(1); }\n', + }); + try { + expect(calls(cg, 'run')).toEqual(['function helper (lib.cpp)']); + } finally { + cg.close(); + } + }); + + it('control: a call above the #define, or under a known-false branch, is still a call', async () => { + const cg = await indexed({ + 'maths.h': [ + '#define FAST_MATH', + '#if defined(FAST_MATH)', + 'float sin_approx(float x);', + '#else', + '#define sin_approx(x) external_sin(x)', + '#endif', + '', + ].join('\n'), + 'maths.cpp': [ + '#include "maths.h"', + 'float sin_approx(float x) { return x; }', + 'void real_fn(int value) {}', + 'float invoke_real(float value) { real_fn(1); return sin_approx(value); }', + '#define real_fn(x) ((void)(x))', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'invoke_real')).toEqual([ + 'function real_fn (maths.cpp)', + 'function sin_approx (maths.cpp)', + ]); + } finally { + cg.close(); + } + }); + + it.each(['c', 'cpp'] as const)('extraction: a function-like macro is a constant carrying its directive (%s)', async (language) => { + await initGrammars(); + await loadGrammarsForLanguages([language]); + const result = extractFromSource( + `marker.${language}`, + '#define TRACE_POINT(value) ((void)(value))\n#define VERSION 1\nvoid f() { TRACE_POINT(VERSION); }\n', + language + ); + const constants = result.nodes.filter((n) => n.kind === 'constant'); + expect(constants.map((n) => [n.name, n.signature])).toEqual([ + ['TRACE_POINT', '#define TRACE_POINT(value) ((void)(value))'], + ]); + // The call ref is still recorded (the resolver decides), the macro is not a function. + expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'TRACE_POINT')).toBe(false); + expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName)).toEqual(['TRACE_POINT']); + }); +}); + +describe('#1839 — local object initialization calls the constructor, not the type', () => { + const REPRODUCTION = [ + 'struct Aggregate {', + ' int value;', + '};', + '', + 'class WithConstructor {', + 'public:', + ' WithConstructor();', + ' explicit WithConstructor(int value);', + '};', + '', + 'WithConstructor::WithConstructor() {}', + 'WithConstructor::WithConstructor(int value) {}', + '', + 'void aggregate_initialization() {', + ' Aggregate item{};', + '}', + '', + 'void constructor_default() {', + ' WithConstructor item;', + '}', + '', + 'void constructor_braced() {', + ' WithConstructor item{};', + '}', + '', + 'void constructor_value() {', + ' WithConstructor item(1);', + '}', + '', + 'void constructor_temporary() {', + ' WithConstructor();', + '}', + '', + ].join('\n'); + + it('reproduction: every initialization form reaches the indexed constructor; the aggregate reaches none', async () => { + const cg = await indexed({ 'case.cpp': REPRODUCTION }); + try { + expect(calls(cg, 'aggregate_initialization')).toEqual([]); + const ctorNodes = cg.getNodesByKind('method').filter((n) => n.qualifiedName === 'WithConstructor::WithConstructor'); + expect(ctorNodes.map((n) => n.signature).sort()).toEqual(['()', '();', '(int value)', '(int value);']); + const bySignature = (caller: string) => + cg + .getCallees(fn(cg, caller).id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => `${r.node.kind} ${r.node.qualifiedName}${r.node.signature}`); + expect(bySignature('constructor_default')).toEqual(['method WithConstructor::WithConstructor()']); + expect(bySignature('constructor_braced')).toEqual(['method WithConstructor::WithConstructor()']); + expect(bySignature('constructor_value')).toEqual(['method WithConstructor::WithConstructor(int value)']); + // The temporary already resolved to a constructor before (#1839); which + // overload a plain `T()` call picks is the generic name matcher's choice. + expect(bySignature('constructor_temporary').every((c) => c.startsWith('method WithConstructor::WithConstructor('))).toBe(true); + // No `calls` edge ever targets the class or struct node. + for (const caller of ['aggregate_initialization', 'constructor_default', 'constructor_braced', 'constructor_value', 'constructor_temporary']) { + const kinds = cg + .getCallees(fn(cg, caller).id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => r.node.kind); + expect(kinds, caller).not.toContain('class'); + expect(kinds, caller).not.toContain('struct'); + } + } finally { + cg.close(); + } + }); + + it('picks the constructor in the lexical namespace of the site, then an enclosing/global type', async () => { + const cg = await indexed({ + 'ns.cpp': [ + 'struct Global { Global() {} };', + 'union Value { Value() {} int x; };', + 'void union_use() { Value v; }', + 'namespace first { struct Widget { Widget() {} }; }', + 'namespace second {', + ' struct Widget { Widget() {} };', + ' void local_use() { Widget w; }', + ' void global_use() { Global g; }', + '}', + 'void explicit_use() { first::Widget w; }', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'union_use')).toEqual(['method Value::Value (ns.cpp)']); + expect(calls(cg, 'local_use')).toEqual(['method second::Widget::Widget (ns.cpp)']); + expect(calls(cg, 'global_use')).toEqual(['method Global::Global (ns.cpp)']); + expect(calls(cg, 'explicit_use')).toEqual(['method first::Widget::Widget (ns.cpp)']); + } finally { + cg.close(); + } + }); + + it('chooses among overloads by arity only when exactly one admits the argument count', async () => { + const cg = await indexed({ + 'overloads.cpp': [ + // (One constructor per line: same-line overloads share a node ID.) + 'struct Widget {', + ' Widget() {}', + ' Widget(int value) {}', + ' Widget(int a, int b = 2) {}', + '};', + 'struct Ambiguous {', + ' Ambiguous(int) {}', + ' Ambiguous(double) {}', + '};', + 'void default_use() { Widget w; }', + 'void two_use() { Widget w(1, 2); }', + 'void one_use() { Widget w(1); }', + 'void ambiguous_use(int value) { Ambiguous w(value); }', + '', + ].join('\n'), + }); + try { + const signatures = (caller: string) => + cg + .getCallees(fn(cg, caller).id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => r.node.signature); + expect(signatures('default_use')).toEqual(['()']); + expect(signatures('two_use')).toEqual(['(int a, int b = 2)']); + // `Widget(int)` and `Widget(int, int = 2)` both admit one argument. + expect(signatures('one_use')).toEqual([]); + expect(signatures('ambiguous_use')).toEqual([]); + } finally { + cg.close(); + } + }); + + it('controls: pointers, references, prototypes and extern declarations construct no object; arrays construct elements', async () => { + const cg = await indexed({ + 'controls.cpp': [ + 'struct Widget {', + ' Widget() {}', + ' Widget(int) {}', + '};', + 'void pointers() { Widget *p{}; Widget *q(nullptr); Widget *arr[2]{}; Widget (*fn)(){}; }', + 'void reference_bind(Widget &other) { Widget &r{other}; Widget &s(other); }', + 'void prototype() { Widget most_vexing(); extern Widget external; }', + 'void array() { Widget items[2]{}; }', + 'void actual() { Widget object{}; }', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'pointers')).toEqual([]); + expect(calls(cg, 'reference_bind')).toEqual([]); + expect(calls(cg, 'prototype')).toEqual([]); + expect(calls(cg, 'array')).toEqual(['method Widget::Widget (controls.cpp)']); + expect(calls(cg, 'actual')).toEqual(['method Widget::Widget (controls.cpp)']); + } finally { + cg.close(); + } + }); + + it('merges separately declared defaults with definitions without confusing same-arity overloads', async () => { + const cg = await indexed({ + 'widget.hpp': [ + 'namespace app {', 'struct Widget {', + ' Widget(int value = 7);', ' Widget(double value);', '};', + 'struct Pair { Pair(int first, int second = 2); };', '}', '', + ].join('\n'), + 'widget.cpp': '#include "widget.hpp"\napp::Widget::Widget(int renamed) {}\napp::Widget::Widget(double renamed) {}\napp::Pair::Pair(int a, int b) {}\n', + 'use.cpp': '#include "widget.hpp"\nint argument(int value) { return value; }\nvoid defaults() { app::Widget item; }\nvoid nested() { app::Pair item(argument(1)); }\nvoid ambiguous() { app::Widget item(1); }\n', + }); + try { + expect(calls(cg, 'defaults')).toEqual(['method app::Widget::Widget (widget.cpp)']); + expect(cg.getCallees(fn(cg, 'defaults').id).find((r) => r.edge.kind === 'calls')?.node.signature).toBe('(int renamed)'); + expect(calls(cg, 'nested')).toEqual(['function argument (use.cpp)', 'method app::Pair::Pair (widget.cpp)']); + expect(calls(cg, 'ambiguous')).toEqual([]); + const ctor = cg.getCallees(fn(cg, 'defaults').id).find((r) => r.edge.kind === 'calls')!.node; + expect(cg.getCallers(ctor.id).map((r) => r.node.name)).toContain('defaults'); + } finally { cg.close(); } + }); + + it('constructs default and explicitly braced array elements, retaining nested argument calls', async () => { + const cg = await indexed({ 'arrays.cpp': [ + 'struct Widget {', ' Widget() {}', ' Widget(int value) {}', '};', + 'int argument() { return 1; }', + 'void plain() { Widget items[2]; }', + 'void empty() { Widget items[2]{}; }', + 'void elements() { Widget items[3]{{argument()}, {2}}; }', + 'void grid() { Widget items[2][2]{{{1}, {2}}, {{3}}}; }', + 'void scalar_elements() { Widget items[2]{1, 2}; }', '', + ].join('\n') }); + try { + for (const name of ['plain', 'empty']) { + expect(cg.getCallees(fn(cg, name).id).filter((r) => r.edge.kind === 'calls').map((r) => r.node.signature)).toEqual(['()']); + } + expect(cg.getCallees(fn(cg, 'grid').id).filter((r) => r.edge.kind === 'calls').map((r) => r.node.signature).sort()).toEqual(['()', '(int value)']); + expect(cg.getCallees(fn(cg, 'scalar_elements').id).filter((r) => r.edge.kind === 'calls').map((r) => r.node.signature)).toEqual(['(int value)']); + expect(cg.getCallees(fn(cg, 'elements').id).filter((r) => r.edge.kind === 'calls').map((r) => r.node.signature ?? r.node.name).sort()) + .toEqual(['()', '(int value)', 'argument']); + } finally { cg.close(); } + }); + + it('MCP preserves constructor retrieval and labels the retained type dependency', async () => { + const cg = await indexed({ 'case.cpp': REPRODUCTION }); + try { + const handler = new ToolHandler(cg); + const result = await handler.execute('codegraph_callees', { symbol: 'constructor_braced' }); + expect(result.isError).not.toBe(true); + const text = result.content?.[0]?.text ?? ''; + expect(text).toContain('WithConstructor (method)'); + expect(text).toContain('via instantiation'); + const owner = cg.getNodesByKind('class').find((n) => n.name === 'WithConstructor')!; + expect([...cg.getImpactRadius(owner.id, 2).nodes.values()].map((n) => n.name)).toContain('constructor_braced'); + const ctor = cg.getCallees(fn(cg, 'constructor_braced').id).find((r) => r.edge.kind === 'calls')!.node; + expect([...cg.getImpactRadius(ctor.id, 2).nodes.values()].map((n) => n.name)).toContain('constructor_braced'); + } finally { cg.close(); } + }); + + it('CLI labels type dependencies separately from executable constructor calls', async () => { + const cg = await indexed({ 'case.cpp': REPRODUCTION }); + const root = cg.getProjectRoot(); + cg.close(); + const cli = (symbol: string, json = false) => execFileSync(process.execPath, + [path.resolve(__dirname, '../dist/bin/codegraph.js'), 'callees', symbol, '-p', root, ...(json ? ['-j'] : [])], + { encoding: 'utf8', env: { ...process.env, CODEGRAPH_NO_WATCH: '1', CODEGRAPH_NO_DAEMON: '1', NO_COLOR: '1' } }); + const aggregate = JSON.parse(cli('aggregate_initialization', true)); + expect(aggregate.callees).toEqual([expect.objectContaining({ kind: 'struct', relationships: ['instantiates'] })]); + const constructed = JSON.parse(cli('constructor_braced', true)); + expect(constructed.callees).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'class', relationships: ['instantiates'] }), + expect.objectContaining({ kind: 'method', relationships: ['calls'] }), + ])); + expect(cli('constructor_braced')).toContain('WithConstructor [instantiates]'); + }); + + it('extraction: one constructor ref per declarator with its own arity; the #1035 instantiates ref is kept', async () => { + await initGrammars(); + await loadGrammarsForLanguages(['cpp']); + const result = extractFromSource( + 'case.cpp', + 'struct Widget { Widget() {} Widget(int value) {} };\nvoid run() { Widget a, b(1), c{}; ns::Other d{1, 2}; Widget *p{}; }\n', + 'cpp' + ); + expect(result.nodes.filter((n) => n.kind === 'method').map((n) => n.signature)).toEqual(['()', '(int value)']); + const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls' || r.referenceKind === 'instantiates'); + expect(refs.map((r) => `${r.referenceKind} ${r.referenceName}`)).toEqual([ + 'instantiates Widget', + 'calls Widget::Widget/0', + 'calls Widget::Widget/1', + 'calls Widget::Widget/0', + 'instantiates Other', + 'calls ns::Other::Other/2', + ]); + }); +}); diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index c88a7b0c7..3a8f0345e 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -4982,6 +4982,13 @@ CPPType* ApiHelper { + const result = extractFromSource('f.cpp', 'struct Widget { Widget(int x = 1); };\nvoid run() { Widget a[2]; Widget b[3]{{2}, {3}}; }'); + expect(result.nodes.find((n) => n.kind === 'method')?.signature).toBe('(int x = 1);'); + expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName)) + .toEqual(['Widget::Widget/0', 'Widget::Widget/1', 'Widget::Widget/1', 'Widget::Widget/0']); + }); + it('strips template args and namespace to the bare class name', () => { // `std::vector v(10)` → `vector`; `ns::Widget w(0)` → `Widget`. expect(instNames('std::vector v(10);')).toEqual(['vector']); diff --git a/__tests__/fixtures/kernel-parity/torture-macros-ctors.cpp b/__tests__/fixtures/kernel-parity/torture-macros-ctors.cpp new file mode 100644 index 000000000..ed0763803 --- /dev/null +++ b/__tests__/fixtures/kernel-parity/torture-macros-ctors.cpp @@ -0,0 +1,66 @@ +/// Parity fixture for #1838 / #1839: function-like macros as `constant` +/// nodes (file scope, inside a namespace, inside a body), constructor +/// signatures, and the per-declarator constructor refs of local object +/// initialization — default, direct, brace, multi-declarator, qualified and +/// template types — beside the declarators that construct nothing (pointer, +/// reference, most-vexing-parse, extern). Must parse ERROR-FREE. +#define TRACE_POINT(value) ((void)(value)) +#define VERSION 7 + +namespace app { +#define APP_LOG(fmt, ...) log_impl(fmt, __VA_ARGS__) + +struct Aggregate { + int value; +}; + +class Widget { +public: + Widget(); + explicit Widget(int value); + Widget(int a, int b = 2) : first(a), second(b) {} + +private: + int first; + int second; +}; + +Widget::Widget() : first(0), second(0) {} +Widget::Widget(int value) : first(value), second(0) {} + +template +struct Box { + Box(T inner) : value(inner) {} + T value; +}; + +void constructions() { + Aggregate item{}; + Widget by_default; + Widget braced{}; + Widget direct(1); + Widget two(1, 2); + Widget a, b(1), c{1, 2}; + app::Widget qualified(3); + Box boxed(4); + Widget(); + TRACE_POINT(VERSION); + APP_LOG("x", 1); +#define LOCAL_TRACE(v) ((void)(v)) + LOCAL_TRACE(1); +} + +void non_constructions(Widget &other) { + Widget *pointer{}; + Widget *null_pointer(nullptr); + Widget &reference{other}; + Widget &bound(other); + Widget most_vexing(); + Widget items[2]{}; // array elements do construct objects + Widget *table[2]{}; + Widget (*callback)(){}; + extern Widget external; + int primitive(5); + int braced_primitive{6}; +} +} // namespace app diff --git a/__tests__/fixtures/kernel-parity/torture-macros.c b/__tests__/fixtures/kernel-parity/torture-macros.c new file mode 100644 index 000000000..9d2e5cdb2 --- /dev/null +++ b/__tests__/fixtures/kernel-parity/torture-macros.c @@ -0,0 +1,15 @@ +/* Parity fixture for #1838 in C: function-like macros as `constant` nodes + * at file scope and inside a body, an object-like define that is NOT a node, + * and the call refs the macro invocations still record. */ +#define TRACE_POINT(value) ((void)(value)) +#define VERSION 7 +#define MAX(a, b) ((a) > (b) ? (a) : (b)) + +static int helper(int x) { return x; } + +int exercise(int value) { + TRACE_POINT(value); +#define LOCAL_TRACE(v) ((void)(v)) + LOCAL_TRACE(1); + return MAX(helper(value), VERSION); +} diff --git a/__tests__/kernel-ccpp-parity.test.ts b/__tests__/kernel-ccpp-parity.test.ts index c05a15d66..720ffb623 100644 --- a/__tests__/kernel-ccpp-parity.test.ts +++ b/__tests__/kernel-ccpp-parity.test.ts @@ -151,6 +151,61 @@ describe.skipIf(!kernelBuilt)('kernel C/C++ extraction parity', () => { assertParity('fixtures/torture.hpp', fs.readFileSync(file, 'utf8'), 'cpp'); }); + it('macro + constructor fixture (cpp): #define constants, constructor signatures, per-declarator ctor refs (#1838/#1839)', () => { + const file = path.join(FIXTURE_DIR, 'torture-macros-ctors.cpp'); + const source = fs.readFileSync(file, 'utf8'); + assertParity('fixtures/torture-macros-ctors.cpp', source, 'cpp'); + // Pin the shapes the resolver relies on, so parity is never empty-vs-empty. + process.env.CODEGRAPH_KERNEL = '0'; + const result = extractFromSource('fixtures/torture-macros-ctors.cpp', source, 'cpp'); + delete process.env.CODEGRAPH_KERNEL; + expect(result.nodes.filter((n) => n.kind === 'constant').map((n) => n.qualifiedName).sort()).toEqual([ + 'TRACE_POINT', + 'app::APP_LOG', + 'app::constructions::LOCAL_TRACE', + ]); + expect( + result.nodes.filter((n) => n.kind === 'method' && n.name === 'Widget').map((n) => n.signature).sort() + ).toEqual(['()', '();', '(int a, int b = 2)', '(int value)', '(int value);']); + const ctorRefs = result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls' && r.referenceName.includes('/')) + .map((r) => r.referenceName); + expect(ctorRefs).toEqual([ + 'Aggregate::Aggregate/0', + 'Widget::Widget/0', + 'Widget::Widget/0', + 'Widget::Widget/1', + 'Widget::Widget/2', + 'Widget::Widget/0', + 'Widget::Widget/1', + 'Widget::Widget/2', + 'app::Widget::Widget/1', + 'Box::Box/1', + 'Widget::Widget/0', + ]); + }); + + it.each(['\n', '\r\n'])('constructor prototypes and array elements remain in parity (%j)', (eol) => { + const source = ['struct Widget {', ' Widget(int value = 1);', '};', + 'Widget::Widget(int renamed) {}', 'int argument() { return 2; }', + 'void run() { Widget plain[2]; Widget empty[2]{}; Widget items[3]{{argument()}, {2}}; Widget grid[2][2]{{{1}, {2}}, {{3}}}; Widget scalars[2]{1, 2}; Widget hex[0x2]{{1}}; Widget sized[2u]{{1}}; }', ''].join(eol); + assertParity('arrays.cpp', source, 'cpp'); + }); + + it('macro fixture (c): #define constants at file scope and inside a body (#1838)', () => { + const file = path.join(FIXTURE_DIR, 'torture-macros.c'); + const source = fs.readFileSync(file, 'utf8'); + assertParity('fixtures/torture-macros.c', source, 'c'); + process.env.CODEGRAPH_KERNEL = '0'; + const result = extractFromSource('fixtures/torture-macros.c', source, 'c'); + delete process.env.CODEGRAPH_KERNEL; + expect(result.nodes.filter((n) => n.kind === 'constant').map((n) => n.name).sort()).toEqual([ + 'LOCAL_TRACE', + 'MAX', + 'TRACE_POINT', + ]); + }); + // Metal rides the cpp route: `.metal` maps to language 'cpp' and the // extension-gated `[[attribute]]` blank must reach the kernel arm through // the route-point preParse hoist (filePath rides along for the gate). @@ -185,6 +240,8 @@ describe.skipIf(!kernelBuilt)('kernel C/C++ extraction parity', () => { ['torture.c', 'c'], ['torture.cpp', 'cpp'], ['torture.hpp', 'cpp'], + ['torture-macros-ctors.cpp', 'cpp'], + ['torture-macros.c', 'c'], ] as const)('torture fixture CRLF parity: %s', (name, lang) => { const file = path.join(FIXTURE_DIR, name); const crlf = fs.readFileSync(file, 'utf8').replace(/(? Walker<'t> { let kind = node.kind(); let mut skip_children = false; + // C/C++ function-like macros become `constant` nodes carrying the + // directive as their signature — a value, never a callee (#1838). + // Mirrors tree-sitter.ts visitNode. + if kind == "preproc_function_def" { + if let Some(name_node) = node.child_by_field_name("name") { + let name = self.text(name_node).to_string(); + let signature = Some(self.text(node).trim().to_string()); + self.create_node("constant", &name, node, Extra { signature, ..Extra::default() }); + } + return; + } + // C++ namespace blocks: prefix-only, no node (#1291/#1093). Anonymous // namespaces fall through to the generic walk. if self.variant == Variant::Cpp && kind == "namespace_definition" { @@ -929,7 +941,10 @@ impl<'t> Walker<'t> { self.maybe_capture_fn_refs(node); - if kind == "function_definition" { + if self.is_cpp_constructor_declaration(node) { + self.extract_method(node); + skip_children = true; + } else if kind == "function_definition" { // functionTypes for both; cpp's methodTypes also lists it, so // inside a class-like scope it extracts as a method. if self.inside_class_like() && self.variant == Variant::Cpp { @@ -1016,6 +1031,7 @@ impl<'t> Walker<'t> { let extra = Extra { docstring: preceding_docstring(node, self.src), + signature: self.constructor_signature(node), visibility: if self.variant == Variant::Cpp { self.visibility_of(node) } else { None }, return_type: self.return_type_of(node), ..Extra::default() @@ -1052,6 +1068,7 @@ impl<'t> Walker<'t> { let extra = Extra { docstring: preceding_docstring(node, self.src), + signature: self.constructor_signature(node), visibility: if self.variant == Variant::Cpp { self.visibility_of(node) } else { None }, is_abstract: if self.variant == Variant::Cpp && self.is_cpp_pure_virtual_method_decl(node) { Some(true) @@ -1544,27 +1561,117 @@ impl<'t> Walker<'t> { } } - /// isCppStackConstruction (#1035). - fn is_cpp_stack_construction(&self, node: Node) -> bool { - let Some(type_node) = node.child_by_field_name("type") else { return false }; + fn is_cpp_constructor_declaration(&self, node: Node<'t>) -> bool { + if self.variant != Variant::Cpp || node.kind() != "declaration" || node.child_by_field_name("type").is_some() { + return false; + } + let Some(owner) = node.parent().and_then(|p| p.parent()) else { return false }; + if !matches!(owner.kind(), "class_specifier" | "struct_specifier" | "union_specifier") { return false; } + let Some(decl) = node.child_by_field_name("declarator") else { return false }; + decl.kind() == "function_declarator" && decl.child_by_field_name("declarator").map(|n| self.text(n)) + == owner.child_by_field_name("name").map(|n| self.text(n)) + } + + /// cppExtractor.getSignature (languages/c-cpp.ts): a C++ constructor + /// definition or class-body prototype carries its parameter + /// list as the signature so a local `T obj(args)` can pick the overload + /// by arity (#1839); a trailing semicolon marks a prototype. Macro-shaped definitions whose real name was + /// recovered from an argument are excluded. + fn constructor_signature(&self, node: Node<'t>) -> Option { + if self.variant != Variant::Cpp || (node.kind() != "function_definition" && !self.is_cpp_constructor_declaration(node)) { + return None; + } + if node.child_by_field_name("type").is_some() || self.recover_cpp_macro_defined_name(node).is_some() { + return None; + } + let params = node.child_by_field_name("declarator")?.child_by_field_name("parameters")?; + Some(format!("{}{}", self.text(params), if node.kind() == "declaration" { ";" } else { "" })) + } + + /// cppStackConstructions (tree-sitter.ts, #1035 / #1839): whether the + /// declaration constructs with arguments (→ `instantiates`), and one + /// arity per constructed object (→ `calls T::T/arity`). `extern`, + /// pointer / reference / function declarators construct nothing; an + /// array's braces hold elements, not constructor arguments. + fn cpp_stack_constructions(&self, node: Node<'t>) -> (bool, Vec) { + let none = (false, Vec::new()); + let Some(type_node) = node.child_by_field_name("type") else { return none }; if !matches!( type_node.kind(), "type_identifier" | "template_type" | "qualified_identifier" ) { - return false; + return none; } + let mut instantiates = false; + let mut arities = Vec::new(); for i in 0..node.named_child_count() { let Some(child) = node.named_child(i) else { continue }; + if child.kind() == "storage_class_specifier" && self.text(child) == "extern" { + return none; + } + if matches!(child.kind(), "identifier" | "array_declarator") { + if child.kind() == "array_declarator" && !self.cpp_object_array(child) { continue; } + arities.push(0); + continue; + } if child.kind() != "init_declarator" { continue; } - if let Some(value) = child.child_by_field_name("value") { - if matches!(value.kind(), "argument_list" | "initializer_list") { - return true; + let Some(declarator) = child.child_by_field_name("declarator") else { continue }; + if !matches!(declarator.kind(), "identifier" | "array_declarator") { + continue; + } + let Some(value) = child.child_by_field_name("value") else { continue }; + if !matches!(value.kind(), "argument_list" | "initializer_list") { + continue; + } + instantiates = true; + if declarator.kind() == "identifier" { + let count = (0..value.named_child_count()) + .filter(|&j| value.named_child(j).map(|n| n.kind() != "comment").unwrap_or(false)) + .count(); + arities.push(count); + } else if self.cpp_object_array(declarator) { + let mut dimensions = Vec::new(); + let mut array = Some(declarator); + while let Some(n) = array.filter(|n| n.kind() == "array_declarator") { + dimensions.insert(0, n.child_by_field_name("size").map(|s| self.text(s)) + .filter(|s| s.bytes().all(|b| b.is_ascii_digit())) + .and_then(|s| s.parse::().ok()).filter(|&n| n <= 9_007_199_254_740_991)); + array = n.child_by_field_name("declarator"); } + Self::cpp_array_arities(value, &dimensions, &mut arities); } } - false + (instantiates, arities) + } + + fn cpp_array_arities(list: Node<'t>, dimensions: &[Option], arities: &mut Vec) { + let entries: Vec<_> = (0..list.named_child_count()).filter_map(|j| list.named_child(j)) + .filter(|n| n.kind() != "comment").collect(); + let mut elided = false; + for entry in &entries { + if dimensions.len() > 1 { + if entry.kind() == "initializer_list" { Self::cpp_array_arities(*entry, &dimensions[1..], arities); } + else { elided = true; } + } else { + arities.push(if entry.kind() == "initializer_list" { + (0..entry.named_child_count()).filter_map(|j| entry.named_child(j)) + .filter(|n| n.kind() != "comment").count() + } else { 1 }); + } + } + if !elided && (entries.is_empty() || dimensions[0].map(|n| n > entries.len()).unwrap_or(false)) { + arities.push(0); + } + } + + fn cpp_object_array(&self, node: Node<'t>) -> bool { + let mut element = node.child_by_field_name("declarator"); + while element.map(|n| n.kind() == "array_declarator").unwrap_or(false) { + element = element.and_then(|n| n.child_by_field_name("declarator")); + } + element.map(|n| n.kind() == "identifier").unwrap_or(false) } /// recordCppFnPtrBinding (tree-sitter.ts:5089). @@ -1660,6 +1767,11 @@ impl<'t> Walker<'t> { fn visit_for_calls_and_structure(&mut self, node: Node<'t>) { stack_guard!(); let kind = node.kind(); + // A function-like macro defined inside a body is still a macro (#1838). + if kind == "preproc_function_def" { + self.visit_node(node); + return; + } self.maybe_capture_fn_refs(node); if kind == "call_expression" { @@ -1668,12 +1780,25 @@ impl<'t> Walker<'t> { self.extract_instantiation(node); } - // C++ stack construction `Calculator calc(0)` / `Widget w{1,2}` (#1035). - if kind == "declaration" - && self.variant == Variant::Cpp - && self.is_cpp_stack_construction(node) - { - self.extract_instantiation(node); + // C++ stack construction `Calculator calc(0)` / `Widget w{1,2}` (#1035), + // plus one constructor ref `ns::T::T/arity` per constructed object (#1839). + if kind == "declaration" && self.variant == Variant::Cpp { + let (instantiates, arities) = self.cpp_stack_constructions(node); + if instantiates { + self.extract_instantiation(node); + } + if !arities.is_empty() && !self.stack.is_empty() { + if let Some(type_node) = node.child_by_field_name("type") { + let from = self.top_row(); + let class_name = strip_cpp_template_args(self.text(type_node)); + if let Some(name) = class_name.split("::").filter(|s| !s.is_empty()).last() { + let calls = edge_kind_index("calls").unwrap(); + for arity in arities { + self.push_ref_at(from, &format!("{class_name}::{name}/{arity}"), calls, node); + } + } + } + } } // C++ local fn-pointer bindings: declarations and branch reassignments. diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 8aaca0e2c..82341a127 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -2250,13 +2250,16 @@ for (const direction of ['callers', 'callees'] as const) { return { group, nodes: [...nodes.values()], edges: [...edges.values()] }; }); + const relationships = (node: Node, edges: Edge[]) => [...new Set(edges + .filter((edge) => (direction === 'callers' ? edge.source : edge.target) === node.id) + .map((edge) => edge.kind))]; if (options.json) { const definitions = collected.map(({ group, nodes, edges }) => { const limited = nodes.slice(0, limit); const shown = new Set(limited.map((node) => node.id)); return { ...cliDefinition(group), - [direction]: limited.map((node) => ({ id: node.id, ...cliNode(node) })), + [direction]: limited.map((node) => ({ id: node.id, ...cliNode(node), relationships: relationships(node, edges) })), edges: edges.filter((edge) => shown.has(direction === 'callers' ? edge.source : edge.target)), total: nodes.length, limit, @@ -2277,7 +2280,8 @@ for (const direction of ['callers', 'callees'] as const) { filteredOut, note, definitions, - [direction]: [...union.values()].slice(0, limit).map(cliNode), + [direction]: [...union.values()].slice(0, limit).map((node) => ({ ...cliNode(node), + relationships: relationships(node, collected.flatMap((entry) => entry.edges)) })), total, limit, truncated: total > limit, @@ -2287,7 +2291,7 @@ for (const direction of ['callers', 'callees'] as const) { if (ambiguous) { console.log(chalk.bold(`\n${title} of "${symbol}" — ${groups.length} distinct definitions (narrow with --file):`)); } - for (const { group, nodes } of collected) { + for (const { group, nodes, edges } of collected) { const limited = nodes.slice(0, limit); const total = nodes.length; const truncated = total > limit; @@ -2304,7 +2308,9 @@ for (const direction of ['callers', 'callees'] as const) { } for (const node of limited) { const loc = node.startLine ? `:${node.startLine}` : ''; - console.log(chalk.cyan(node.kind.padEnd(12)) + chalk.white(node.name)); + const kinds = relationships(node, edges).filter((kind) => kind !== 'calls'); + const relation = kinds.length ? ` [${kinds.join(', ')}]` : ''; + console.log(chalk.cyan(node.kind.padEnd(12)) + chalk.white(node.name) + chalk.dim(relation)); console.log(chalk.dim(` ${node.filePath}${loc}`)); console.log(); } diff --git a/src/extraction/languages/c-cpp.ts b/src/extraction/languages/c-cpp.ts index bd7c8dbdf..c4aa31a4f 100644 --- a/src/extraction/languages/c-cpp.ts +++ b/src/extraction/languages/c-cpp.ts @@ -539,7 +539,7 @@ export function blankMetalAttributes(source: string): string { * removed in full. The pipeline masks once, and individual paren blankers also * use this helper so they are safe when called directly. */ -function maskCppRawStrings(source: string): { source: string; restore: (blanked: string) => string } { +export function maskCppRawStrings(source: string): { source: string; restore: (blanked: string) => string } { const unchanged = { source, restore: (blanked: string): string => blanked }; if (source.indexOf('R"') === -1) return unchanged; // Skip comments and ordinary literals before looking for a raw opener. The @@ -1763,6 +1763,16 @@ function preParseCSource(source: string): string { return rawStrings.restore(blankCNamedVariadicDefineDots(restoreDirectiveLines(source, blanked))); } +/** A constructor prototype carries defaults even when its body is in another file. */ +export function isCppConstructorDeclaration(node: SyntaxNode): boolean { + if (node.type !== 'declaration' || getChildByField(node, 'type')) return false; + const owner = node.parent?.parent; + if (!owner || !['class_specifier', 'struct_specifier', 'union_specifier'].includes(owner.type)) return false; + const declarator = getChildByField(node, 'declarator'); + return declarator?.type === 'function_declarator' + && getChildByField(declarator, 'declarator')?.text === getChildByField(owner, 'name')?.text; +} + export const cppExtractor: LanguageExtractor = { // Recover macro-annotated class/struct definitions (`class MYMODULE_API Foo : Base`, // #1061/#946) and macro-prefixed functions (`FORCEINLINE FString Foo()`, #1093 @@ -1806,6 +1816,18 @@ export const cppExtractor: LanguageExtractor = { resolveName: extractCppQualifiedMethodName, getReceiverType: extractCppReceiverType, getReturnType: extractCppReturnType, + // Constructors (definitions and class-body declarations) carry their + // parameter list as the signature; a trailing semicolon marks a prototype, so a local `T obj(args)` can be matched + // to the one overload with a compatible arity (#1839). Macro-shaped + // definitions whose real name was recovered from an argument are excluded: + // their "parameters" are macro arguments. Mirrored in the kernel. + getSignature: (node, source) => { + if ((node.type !== 'function_definition' && !isCppConstructorDeclaration(node)) || getChildByField(node, 'type')) return undefined; + if (recoverCppMacroDefinedName(node, source)) return undefined; + const declarator = getChildByField(node, 'declarator'); + const parameters = declarator && getChildByField(declarator, 'parameters'); + return parameters ? getNodeText(parameters, source) + (node.type === 'declaration' ? ';' : '') : undefined; + }, getVisibility: (node) => { // Check for access specifier in parent const parent = node.parent; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 5304a534d..19c16471a 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -21,7 +21,7 @@ import { FN_REF_SPECS, captureFnRefCandidates, type FnRefSpec, type FnRefCandida import { isGeneratedFile } from './generated-detection'; import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types'; import { EXTRACTORS } from './languages'; -import { stripCppTemplateArgs } from './languages/c-cpp'; +import { stripCppTemplateArgs, isCppConstructorDeclaration } from './languages/c-cpp'; import { rustImplTypeName } from './languages/rust'; import { LiquidExtractor } from './liquid-extractor'; import { RazorExtractor } from './razor-extractor'; @@ -1092,6 +1092,27 @@ export class TreeSitterExtractor { if (skipChildren) return; } + if (this.language === 'cpp' && isCppConstructorDeclaration(node)) { + this.extractMethod(node); + return; + } + + // C/C++ function-like macros (`#define TRACE(x) ...`) become `constant` + // nodes carrying the directive as their signature. A macro is a value, + // never an executable callee: the resolver reads these to recognize a + // call whose name is a macro visible in the translation unit and refuses + // to bind it to a same-named function elsewhere (#1838). Mirrored in the + // kernel (ccpp/mod.rs visit_node). + if ((this.language === 'c' || this.language === 'cpp') && nodeType === 'preproc_function_def') { + const name = getChildByField(node, 'name'); + if (name) { + this.createNode('constant', getNodeText(name, this.source), node, { + signature: getNodeText(node, this.source).trim(), + }); + } + return; + } + // C++ namespace blocks: carry the namespace name as a qualifiedName prefix // while walking the body, so `namespace flash { void compute_attn(); }` // indexes compute_attn with qualifiedName `flash::compute_attn` and a @@ -5192,26 +5213,34 @@ export class TreeSitterExtractor { } /** - * Is this C++ `declaration` a stack/direct-initialization object construction - * that invokes a constructor — `Calculator calc(0)` (direct-init) or - * `Widget w{1, 2}` (brace-init) — as opposed to a plain variable or a - * function declaration? Used to emit an `instantiates` edge for the + * C++ stack construction — `Calculator calc(0)` / `Widget w{1, 2}` — is * call-less construction syntax (#1035); heap `new T(...)` is handled * separately by INSTANTIATION_KINDS. * - * Two signals, both required: - * - the `type` field is a class-like NAMED type (`type_identifier`, - * `template_type`, or `qualified_identifier`). Primitives (`int x(0)`), - * `auto` (`placeholder_type_specifier` — that form always carries a real - * `call_expression`, already handled), and sized specifiers are excluded — - * they construct no class; and - * - a declarator carries constructor arguments: an `init_declarator` whose - * `value` is an `argument_list` (`(args)`) or `initializer_list` (`{args}`). - * This skips default construction `Calculator c;` (no value) and the - * most-vexing-parse `Calculator c();` (a bodyless `function_declarator`, - * a function decl — not a construction). + * The `type` field must be a class-like NAMED type (`type_identifier`, + * `template_type`, or `qualified_identifier`). Primitives (`int x(0)`), + * `auto` (`placeholder_type_specifier` — that form always carries a real + * `call_expression`, already handled), and sized specifiers are excluded — + * they construct no class. `extern T x;` declares, it constructs nothing. + * + * Per declarator: + * - a bare `identifier` is default construction (`T item;`) — arity 0; + * - an `init_declarator` whose `value` is an `argument_list` (`(args)`) or + * `initializer_list` (`{args}`) carries constructor arguments — arity is + * the argument count. An array declarator's braces hold ELEMENTS, not + * constructor arguments, so each braced element has its own arity; + * - pointer / reference / function declarators construct nothing: + * `T* p{}` is a null pointer, `T& r{x}` binds a reference, and the + * most-vexing-parse `T c();` is a function declaration. + * + * `instantiates` (the type) is retained for initialized declarations, + * preserving the #1035 dependency edges. `calls` (`T::T/arity`, resolved to + * the constructor by src/resolution/cpp-constructor.ts, #1839) is emitted + * for every declarator with an arity, default construction included. + * Mirrored in the kernel (ccpp/mod.rs cpp_stack_constructions). */ - private isCppStackConstruction(node: SyntaxNode): boolean { + private cppStackConstructions(node: SyntaxNode): { instantiates: boolean; arities: number[] } { + const none = { instantiates: false, arities: [] }; const typeNode = getChildByField(node, 'type'); if ( !typeNode || @@ -5219,17 +5248,59 @@ export class TreeSitterExtractor { typeNode.type !== 'template_type' && typeNode.type !== 'qualified_identifier') ) { - return false; + return none; } + let instantiates = false; + const arities: number[] = []; for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); - if (child?.type !== 'init_declarator') continue; + if (!child) continue; + if (child.type === 'storage_class_specifier' && getNodeText(child, this.source) === 'extern') return none; + if (child.type === 'identifier' || child.type === 'array_declarator') { + if (child.type === 'array_declarator' && !this.cppObjectArray(child)) continue; + arities.push(0); + continue; + } + if (child.type !== 'init_declarator') continue; + const declarator = getChildByField(child, 'declarator'); + if (declarator?.type !== 'identifier' && declarator?.type !== 'array_declarator') continue; const value = getChildByField(child, 'value'); - if (value && (value.type === 'argument_list' || value.type === 'initializer_list')) { - return true; + if (!value || (value.type !== 'argument_list' && value.type !== 'initializer_list')) continue; + instantiates = true; + if (declarator.type === 'identifier') { + arities.push(value.namedChildren.filter((c) => c.type !== 'comment').length); + } else if (this.cppObjectArray(declarator)) { + const dimensions: number[] = []; + let array: SyntaxNode | null = declarator; + while (array?.type === 'array_declarator') { + const size = getChildByField(array, 'size')?.text ?? ''; + dimensions.unshift(/^\d+$/.test(size) && Number.isSafeInteger(Number(size)) ? Number(size) : NaN); + array = getChildByField(array, 'declarator'); + } + const elements = (list: SyntaxNode, depth: number): void => { + const entries = list.namedChildren.filter((c) => c.type !== 'comment'); + let elided = false; + for (const entry of entries) { + if (depth + 1 < dimensions.length) { + if (entry.type === 'initializer_list') elements(entry, depth + 1); + else elided = true; // Unbraced multidimensional layout needs type information. + } else { + arities.push(entry.type === 'initializer_list' + ? entry.namedChildren.filter((c) => c.type !== 'comment').length : 1); + } + } + if (!elided && (entries.length === 0 || dimensions[depth]! > entries.length)) arities.push(0); + }; + elements(value, 0); } } - return false; + return { instantiates, arities }; + } + + private cppObjectArray(node: SyntaxNode): boolean { + let element = getChildByField(node, 'declarator'); + while (element?.type === 'array_declarator') element = getChildByField(element, 'declarator'); + return element?.type === 'identifier'; } /** @@ -5786,6 +5857,12 @@ export class TreeSitterExtractor { const visitForCallsAndStructure = (node: SyntaxNode): void => { const nodeType = node.type; + // A function-like macro defined inside a body is still a macro (#1838). + if ((this.language === 'c' || this.language === 'cpp') && nodeType === 'preproc_function_def') { + this.visitNode(node); + return; + } + // Function-as-value capture (#756) — function bodies are walked here, // not in visitNode, so the capture hook must fire in both walkers. this.maybeCaptureFnRefs(node, nodeType); @@ -5835,8 +5912,29 @@ export class TreeSitterExtractor { // (which strips template args / namespace and emits the `instantiates` // ref). Children still recurse below, so a nested ctor-arg call // (`Calculator calc(make())`) keeps its own `calls` ref. - if (nodeType === 'declaration' && this.language === 'cpp' && this.isCppStackConstruction(node)) { - this.extractInstantiation(node); + if (nodeType === 'declaration' && this.language === 'cpp') { + const { instantiates, arities } = this.cppStackConstructions(node); + if (instantiates) this.extractInstantiation(node); + // One `calls` ref per constructed object, naming the constructor and + // its argument count (`ns::T::T/1`) so the resolver can pick the + // overload — a type is not a callee (#1839). + const callerId = this.nodeStack[this.nodeStack.length - 1]; + const typeNode = getChildByField(node, 'type'); + if (callerId && typeNode && arities.length) { + const className = stripCppTemplateArgs(getNodeText(typeNode, this.source)); + const name = className.split('::').filter(Boolean).pop(); + if (name) { + for (const arity of arities) { + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: `${className}::${name}/${arity}`, + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + } } // C++ local function-pointer bindings (see cppLocalFnPtrs): record diff --git a/src/resolution/cpp-constructor.ts b/src/resolution/cpp-constructor.ts new file mode 100644 index 000000000..92d2f4d7a --- /dev/null +++ b/src/resolution/cpp-constructor.ts @@ -0,0 +1,126 @@ +/** + * Local C++ object construction → the constructor it runs (#1839). + * + * `T obj;` / `T obj(args);` / `T obj{args};` carry no call node, so + * extraction records a dedicated `calls` ref shaped `ns::T::T/` + * (tree-sitter.ts cppStackConstructions). This module resolves it: + * - the type is looked up in the LEXICAL namespaces of the call site — for + * `second::use()` writing `Widget w;`, `second::Widget` before a global + * `Widget`; a `::T` spelling is global only; + * - among that type's constructors, the single overload whose parameter + * count admits the argument count wins (defaults and `...` widen a range); + * two admitting overloads (`T(int)` / `T(double)` for `T w(x)`) resolve to + * nothing rather than a guess; + * - a type with no indexed constructor (an aggregate) yields no edge — a + * type declaration is not a callee. + * These refs never fall through to the generic name strategies. + */ +import type { Node } from '../types'; +import type { ResolvedRef, ResolutionContext, UnresolvedRef } from './types'; + +const CONSTRUCTOR_REF = /^(.*)::([^:]+)\/(\d+)$/; + +export function isCppConstructorRef(ref: UnresolvedRef): boolean { + return ref.language === 'cpp' && ref.referenceKind === 'calls' && /::[^:]+\/\d+$/.test(ref.referenceName); +} + +/** Admissible argument counts of a `(params)` signature; null when it can't be read. */ +function constructorShape(signature: string | undefined): { min: number; max: number; key: string } | null { + signature = signature?.replace(/;$/, ''); + if (!signature?.startsWith('(') || !signature.endsWith(')')) return null; + const text = signature.slice(1, -1).trim(); + if (!text || text === 'void') return { min: 0, max: 0, key: '' }; + // Split on top-level commas only: `std::map`, `int (*cb)(int, int)` + // and `T x = f(a, b)` all nest their commas. + const parts: string[] = []; + let start = 0; + let depth = 0; + let quote = ''; + for (let i = 0; i < text.length; i++) { + const c = text[i]!; + if (quote) { + if (c === '\\') i++; + else if (c === quote) quote = ''; + continue; + } + if (c === '"' || c === "'") { + quote = c; + continue; + } + if ('(<[{'.includes(c)) depth++; + if (')>]}'.includes(c)) depth--; + if (c === ',' && depth === 0) { + parts.push(text.slice(start, i)); + start = i + 1; + } + } + if (depth !== 0 || quote) return null; + parts.push(text.slice(start)); + // A comparison in a default argument would be mistaken for a `=` default + // or a template bracket — leave those to a compiler. + if (parts.some((p) => /[<>]=|==|!=/.test(p))) return null; + const variadic = parts.some((p) => p.includes('...')); + const types = parts.map((p) => { + let type = p.split('=')[0]!.trim(); + // Strip an optional parameter name, retaining unnamed built-in types + // (`unsigned int`, `long long`) and qualifiers. Complex declarators stay + // distinct unless their spelling matches; uncertainty must not merge overloads. + type = type.replace(/(.*[\s*&>])([A-Za-z_]\w*)$/, (whole, prefix: string, name: string) => + /^(?:void|bool|char|short|int|long|float|double|signed|unsigned|const|volatile)$/.test(name) + || /^(?:const|volatile|struct|class|enum)\s*$/.test(prefix) ? whole : prefix); + return type.replace(/\s+/g, ''); + }); + return { + key: types.join(','), + min: parts.filter((p) => !p.includes('=') && !p.includes('...')).length, + max: variadic ? Infinity : parts.length, + }; +} + +export function matchCppConstructor(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + const match = ref.referenceName.match(CONSTRUCTOR_REF); + if (!match) return null; + const [, rawType, name, count] = match; + const type = rawType!.replace(/^::/, ''); + if (type.split('::').pop() !== name) return null; + const argc = Number(count); + + // Innermost lexical namespace first, then outward, then global. + const caller = context.getNodeById?.(ref.fromNodeId); + const scopes = rawType!.startsWith('::') ? [] : (caller?.qualifiedName.split('::') ?? []); + const candidates: string[] = []; + for (let i = scopes.length; i > 0; i--) candidates.push(`${scopes.slice(0, i).join('::')}::${type}`); + candidates.push(type); + + for (const qualified of candidates) { + const owners = context + .getNodesByQualifiedName(qualified) + .filter((n) => n.language === 'cpp' && (n.kind === 'class' || n.kind === 'struct' || n.kind === 'union')); + if (owners.length === 0) continue; + const constructors = context + .getNodesByName(name!) + .filter((n) => n.language === 'cpp' && n.kind === 'method' && n.qualifiedName === `${qualified}::${name}`); + // Brace-init prefers an initializer_list overload over arity — that + // choice needs the argument types, so decline. + if (constructors.some((n) => /\binitializer_list\b/.test(n.signature ?? ''))) return null; + // A prototype and its out-of-line definition describe one overload. + // Merge their admissible ranges, then prefer the executable definition. + const overloads = new Map(); + for (const node of constructors) { + const shape = constructorShape(node.signature); + if (!shape) return null; + const prior = overloads.get(shape.key); + if (prior) { + prior.nodes.push(node); + prior.min = Math.min(prior.min, shape.min); + } else overloads.set(shape.key, { nodes: [node], min: shape.min, max: shape.max }); + } + const admitting = [...overloads.values()].filter((o) => o.min <= argc && argc <= o.max); + if (admitting.length !== 1) return null; + const definitions = admitting[0]!.nodes.filter((n) => !n.signature?.endsWith(';')); + const targets = definitions.length > 0 ? definitions : admitting[0]!.nodes; + if (targets.length !== 1) return null; + return { original: ref, targetNodeId: targets[0]!.id, confidence: 0.9, resolvedBy: 'qualified-name' }; + } + return null; +} diff --git a/src/resolution/cpp-macro-visibility.ts b/src/resolution/cpp-macro-visibility.ts new file mode 100644 index 000000000..f9946c1cc --- /dev/null +++ b/src/resolution/cpp-macro-visibility.ts @@ -0,0 +1,326 @@ +/** + * Is a C/C++ call's name a function-like macro visible at the call site? + * + * `TRACE_POINT(1)` parses as a call_expression, so extraction records a + * `calls` ref named `TRACE_POINT`. If the translation unit defines + * `#define TRACE_POINT(value) ...` (in the file itself or through an + * include), the "call" is a macro expansion — and binding it to a + * same-spelled free function in an unrelated file fabricates a caller and a + * callee that never existed (#1838). The resolver asks this before trying + * any strategy and drops such a ref. + * + * Directive summaries are cached per file, but evaluated in translation-unit + * order on every inclusion. Only #pragma once and actual guard state suppress + * reinclusion; an active recursion stack breaks include cycles. Unknown build + * flags remain possible, so only definite macro visibility suppresses a call. + * Object-like definitions participate in conditions but never enter the cached + * call-site timelines (vendor headers can contain tens of thousands of them). + */ +import * as path from 'path'; +import { maskCppRawStrings } from '../extraction/languages/c-cpp'; +import { CPP_DEFINE_SIGNATURE, type ResolutionContext, type UnresolvedRef } from './types'; +import { resolveImportPath } from './import-resolver'; + +/** Three-valued: true / false / undefined = depends on an unknown build flag. */ +type Truth = boolean | undefined; +type FileEvent = + | { kind: 'define' | 'undef'; name: string; line: number; functionLike: boolean; value: string; wrapsItself: boolean } + | { kind: 'include'; quote: string; spec: string; line: number } + | { kind: 'branch'; op: string; expression: string; guard: boolean; line: number } + | { kind: 'once'; line: number }; +type Event = { line: number; defined: Truth }; +type Cache = { + summaries: Map; + includes: Map; + /** Indexed files by basename, for `#include "dir/name.h"` that no include root explains. */ + byBasename: Map | null; + /** Per root file: macro name → define/undef events in root-file line order. */ + roots: Map>; +}; + +const memo = new WeakMap(); +const ROOT_TIMELINE_CAP = 32; + +const and = (a: Truth, b: Truth): Truth => + a === false || b === false ? false : a === true && b === true ? true : undefined; +const or = (a: Truth, b: Truth): Truth => + a === true || b === true ? true : a === false && b === false ? false : undefined; +const not = (a: Truth): Truth => (a === undefined ? undefined : !a); + +export function clearCppMacroVisibility(context: ResolutionContext): void { + memo.delete(context); +} + +const isDefine = (n: { kind: string; signature?: string }): boolean => + n.kind === 'constant' && CPP_DEFINE_SIGNATURE.test(n.signature ?? ''); + +/** + * Is this C/C++ `calls` ref a macro expansion rather than a call? True when + * the index knows the name as a function-like macro (extraction mints a + * `constant` per `preproc_function_def`) and either nothing but macros bears + * the name — there is no function to call, and a case-insensitive fuzzy + * match (`SWAP` → `swap`) must not invent one — or the macro is definitely + * visible at the call site. + */ +export function isVisibleCppMacro(ref: UnresolvedRef, context: ResolutionContext): boolean { + if (ref.language !== 'c' && ref.language !== 'cpp') return false; + if (ref.referenceKind !== 'calls' || !/^\w+$/.test(ref.referenceName)) return false; + // The name cache is the index's truth, invalidated with the resolver's other + // caches; a per-context name set would go stale between indexing batches. + const sameName = context.getNodesByName(ref.referenceName); + if (!sameName.some(isDefine)) return false; + if (!sameName.some((n) => !isDefine(n))) return true; + const cache = cacheFor(context); + + const rootKey = `${ref.language}\0${ref.filePath}`; + let timeline = cache.roots.get(rootKey); + if (!timeline) { + timeline = walkTranslationUnit(ref.filePath, ref.language, context, cache); + if (cache.roots.size >= ROOT_TIMELINE_CAP) cache.roots.delete(cache.roots.keys().next().value!); + cache.roots.set(rootKey, timeline); + } + const before = (timeline.get(ref.referenceName) ?? []).filter((e) => e.line <= ref.line); + return before.length > 0 && before[before.length - 1]!.defined === true; +} + +function cacheFor(context: ResolutionContext): Cache { + let cache = memo.get(context); + if (!cache) { + cache = { summaries: new Map(), includes: new Map(), byBasename: null, roots: new Map() }; + memo.set(context, cache); + } + return cache; +} + +/** Cache syntax, never conditional truth: an included file can change its flags. */ +function summarize(file: string, context: ResolutionContext, cache: Cache): FileEvent[] { + const cached = cache.summaries.get(file); + if (cached) return cached; + const events: FileEvent[] = []; + const lines = directiveLines(context.readFile(file) ?? ''); + for (let i = 0; i < lines.length; i++) { + const text = lines[i]!; + const branch = text.match(/^\s*#\s*(ifdef|ifndef|if|elif|else|endif)\b(.*)$/); + if (branch) { + events.push({ kind: 'branch', op: branch[1]!, expression: branch[2]!, + guard: guardsItself(lines, i, branch[1]!, branch[2]!), line: i + 1 }); + continue; + } + const directive = text.match(/^\s*#\s*(define|undef)\s+(\w+)(\(?)/); + if (directive) { + events.push({ kind: directive[1] as 'define' | 'undef', name: directive[2]!, line: i + 1, + functionLike: directive[3] === '(', value: text.slice(directive[0].length), + wrapsItself: directive[3] === '(' && callsItself(lines, i, directive[2]!) }); + continue; + } + const include = text.match(/^\s*#\s*include\s*([<"])([^>"]+)[>"]/); + if (include) events.push({ kind: 'include', quote: include[1]!, spec: include[2]!, line: i + 1 }); + if (/^\s*#\s*pragma\s+once\b/.test(text)) events.push({ kind: 'once', line: i + 1 }); + } + cache.summaries.set(file, events); + return events; +} + +/** + * The file's lines with comments removed — only as far as the preprocessor + * needs: a line inside a block comment is blank, a directive line loses its + * trailing `//` / `/* … *\/`, and every other line is kept verbatim (its + * content is never read, only whether a block comment opens on it). The + * generic comment stripper is a regex pass over the whole file and this walk + * touches every header of a translation unit, vendor trees included. + */ +function directiveLines(source: string): string[] { + const out: string[] = []; + let inBlock = false; + for (const raw of maskCppRawStrings(source).source.split(/\r?\n/)) { + let text = raw; + if (inBlock) { + const end = text.indexOf('*/'); + if (end < 0) { + out.push(''); + continue; + } + text = text.slice(end + 2); + inBlock = false; + } + const directive = /^\s*#/.test(text); + let kept = ''; + let quote = ''; + for (let i = 0; i < text.length; i++) { + const c = text[i]!; + if (quote) { + if (c === '\\') i++; + else if (c === quote) quote = ''; + continue; + } + if (c === '"' || c === "'") { + quote = c; + continue; + } + if (c === '/' && text[i + 1] === '/') { + kept = text.slice(0, i); + break; + } + if (c === '/' && text[i + 1] === '*') { + const end = text.indexOf('*/', i + 2); + if (end < 0) { + inBlock = true; + kept = text.slice(0, i); + break; + } + text = text.slice(0, i) + ' ' + text.slice(end + 2); + i--; + continue; + } + } + out.push(directive ? kept || text : raw); + } + return out; +} + +/** Does the body of the `#define NAME(` at `index` (continuation lines included) call `NAME`? */ +function callsItself(lines: string[], index: number, name: string): boolean { + let text = lines[index]!; + for (let j = index; /\\\s*$/.test(lines[j]!) && j + 1 < lines.length; j++) text += ' ' + lines[j + 1]!; + const body = text.slice(text.indexOf('(') + 1); + return new RegExp(`(?:\\b${name}\\s*\\(|\\(\\s*${name}\\s*\\)\\s*\\()`).test(body); +} + +/** + * The include-guard idiom: `#ifndef X_H` (or `#if !defined(X_H)`) whose next + * directive is `#define X_H`. Nothing in the file defines the guard before + * the test, so this is the first inclusion and the guarded body is active. + * The same shape around a fallback function-like macro (`#ifndef MIN` / + * `#define MIN(a, b) …`) is read the same way. A default VALUE + * (`#ifndef ENABLE_X` / `#define ENABLE_X 0`) is not: that is the flag a + * build overrides on the command line, so it stays unknown. + */ +function guardsItself(lines: string[], index: number, op: string, expression: string): boolean { + const name = + op === 'ifndef' + ? expression.trim() + : expression.match(/^\s*!\s*defined\s*(?:\(\s*(\w+)\s*\)|(\w+))\s*$/)?.slice(1).find(Boolean); + if (!name || !/^\w+$/.test(name)) return false; + for (let j = index + 1; j < lines.length; j++) { + const text = lines[j]!; + if (!/^\s*#/.test(text)) continue; + return new RegExp(`^\\s*#\\s*define\\s+${name}(?:\\s*$|\\()`).test(text); + } + return false; +} + +function resolveInclude( + file: string, + quote: string, + spec: string, + language: UnresolvedRef['language'], + context: ResolutionContext, + cache: Cache +): string | null { + const key = `${language}\0${file}\0${quote}${spec}`; + const cached = cache.includes.get(key); + if (cached !== undefined) return cached; + const local = path.posix.normalize(path.posix.join(path.posix.dirname(file), spec.replace(/\\/g, '/'))); + let target = + quote === '"' && !local.startsWith('../') && !path.posix.isAbsolute(local) && context.fileExists(local) + ? local + : resolveImportPath(spec, file, language, context); + if (!target) { + if (!cache.byBasename) { + cache.byBasename = new Map(); + for (const f of context.getAllFiles()) { + const base = f.slice(f.lastIndexOf('/') + 1); + const list = cache.byBasename.get(base); + if (list) list.push(f); + else cache.byBasename.set(base, [f]); + } + } + const normalized = spec.replace(/\\/g, '/'); + const matches = (cache.byBasename.get(normalized.slice(normalized.lastIndexOf('/') + 1)) ?? []).filter( + (f) => f === normalized || f.endsWith('/' + normalized) + ); + if (matches.length === 1) target = matches[0]!; + } + cache.includes.set(key, target); + return target; +} + +function walkTranslationUnit( + rootFile: string, + language: UnresolvedRef['language'], + context: ResolutionContext, + cache: Cache +): Map { + const timeline = new Map(); + const definitions = new Map(); + const scanning = new Set(); + const macroNames = new Set(); + const once = new Map(); + const condition = (expression: string): Truth => { + const text = expression.trim(); + if (/^(?:0x[\da-f]+|\d+)[uUlL]*$/i.test(text)) return Number(text.replace(/[uUlL]+$/, '')) !== 0; + const def = text.match(/^(!)?\s*defined\s*(?:\(\s*(\w+)\s*\)|(\w+))$/); + if (def) { + const known = definitions.get(def[2] ?? def[3]!)?.defined; + return def[1] ? not(known) : known; + } + return /^\w+$/.test(text) ? definitions.get(text)?.value : undefined; + }; + const scan = (file: string, inherited: Truth, includeLine?: number): void => { + if (inherited === false || scanning.has(file) || once.get(file) === true) return; + scanning.add(file); + let active: Truth = inherited; + const frames: Array<{ parent: Truth; taken: Truth }> = []; + for (const ev of summarize(file, context, cache)) { + const line = includeLine ?? ev.line; + if (ev.kind === 'branch') { + if (ev.op === 'if' || ev.op === 'ifdef' || ev.op === 'ifndef') { + const known = definitions.get(ev.expression.trim())?.defined; + let selected = ev.op === 'if' ? condition(ev.expression) : ev.op === 'ifndef' ? not(known) : known; + if (selected === undefined && ev.guard) selected = true; + frames.push({ parent: active, taken: selected }); + active = and(active, selected); + } else if (ev.op === 'endif') { + active = frames.pop()?.parent ?? inherited; + } else { + const frame = frames[frames.length - 1]; + if (frame) { + const test = ev.op === 'else' ? true : condition(ev.expression); + active = and(frame.parent, and(not(frame.taken), test)); + frame.taken = or(frame.taken, test); + } + } + continue; + } + if (active === false) continue; + if (ev.kind === 'once') { + once.set(file, or(once.get(file) ?? false, active)); + continue; + } + if (ev.kind === 'include') { + const target = resolveInclude(file, ev.quote, ev.spec, language, context, cache); + if (target) scan(target, active, line); + continue; + } + const prior = definitions.get(ev.name); + const defining = ev.kind === 'define'; + const macro = defining && ev.functionLike && !ev.wrapsItself; + const now = active === true ? macro : prior?.macro === macro ? macro : undefined; + definitions.set(ev.name, { + defined: defining ? or(prior?.defined ?? false, active) : and(prior?.defined ?? false, not(active)), + value: defining && active === true ? condition(ev.value) : undefined, + macro: now, + }); + if (ev.functionLike) macroNames.add(ev.name); + if (macroNames.has(ev.name)) { + const events = timeline.get(ev.name) ?? []; + events.push({ line, defined: now }); + timeline.set(ev.name, events); + } + } + scanning.delete(file); + }; + + scan(rootFile, true); + return timeline; +} diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 43c2fd260..0d84ed46e 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -19,8 +19,11 @@ import { isSupertypeTarget, isInheritanceRef, isImportableKind, + CPP_DEFINE_SIGNATURE, } from './types'; import { matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher'; +import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; +import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { resolveViaImport, resolvePhpImportedStaticCall, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath } from './import-resolver'; import { ResolverPool, minRefsForPool } from './resolver-pool'; import { resolveAliasBinding } from './alias-binding'; @@ -414,6 +417,7 @@ export class ReferenceResolver { if (this.context) { clearImportResolverMemos(this.context); clearNameMatcherMemos(this.context); + clearCppMacroVisibility(this.context); } } @@ -889,6 +893,10 @@ export class ReferenceResolver { * the alias names (see ./alias-binding), regardless of the strategy. */ resolveOne(ref: UnresolvedRef): ResolvedRef | null { + // A C/C++ "call" whose name is a function-like macro visible in this + // translation unit is a macro expansion, not a call — it must never bind + // to a same-named function in another file (#1838). + if (isVisibleCppMacro(ref, this.context)) return null; const candidate = this.gateTargetKind(this.resolveOneInner(ref), ref); const resolved = candidate?.resolvedBy === 'framework' ? this.gateFrameworkLanguage(candidate, ref) @@ -911,6 +919,10 @@ export class ReferenceResolver { } private resolveOneInner(ref: UnresolvedRef): ResolvedRef | null { + // A local C++ object construction (`T obj(args)`, ref `ns::T::T/1`) + // resolves ONLY to a constructor of the lexically nearest `T` (#1839). + if (isCppConstructorRef(ref)) return matchCppConstructor(ref, this.context); + // Skip built-in/external references if (this.isBuiltInOrExternal(ref)) { return null; @@ -2670,6 +2682,13 @@ export class ReferenceResolver { private gateTargetKind(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null { if (!result) return result; + // A `#define` is a value, never a callee (#1838): a macro defined only in + // an unrelated file is not what `NAME(x)` here expands to either. + if (ref.referenceKind === 'calls') { + const target = this.queries.getNodeById(result.targetNodeId); + if (target?.kind === 'constant' && CPP_DEFINE_SIGNATURE.test(target.signature ?? '')) return null; + } + // An `imports` reference names something importable — never a member that // only exists inside a type. if (ref.referenceKind === 'imports') { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 04f3a3707..ac2470b17 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import { Language, Node } from '../types'; -import { UnresolvedRef, ResolvedRef, ResolutionContext, isSupertypeTarget, isInheritanceRef, isImportableKind } from './types'; +import { UnresolvedRef, ResolvedRef, ResolutionContext, isSupertypeTarget, CPP_DEFINE_SIGNATURE, isInheritanceRef, isImportableKind } from './types'; import { blankStringContents, stripCommentsForRegex } from './strip-comments'; import { JS_BUILT_INS, JS_BUILTIN_METHODS, TS_PRIMITIVE_TYPES } from './js-builtins'; /** @@ -981,6 +981,11 @@ export function matchByExactName( if (storeAction) return storeAction; } const candidates = context.getNodesByName(ref.referenceName) + // Macro constants are not callees and must not consume the same-name + // ceiling (#1839). Keep upstream's language gate on the chosen result. + .filter((n) => !(ref.referenceKind === 'calls' && + (ref.language === 'c' || ref.language === 'cpp') && + n.kind === 'constant' && CPP_DEFINE_SIGNATURE.test(n.signature ?? ''))) // Type/value references retain same-family eligibility: a native namesake // must not hide the actual web type. Calls still gate only the winner. .filter((n) => (ref.referenceKind !== 'references' && ref.referenceKind !== 'function_ref') || diff --git a/src/resolution/types.ts b/src/resolution/types.ts index 42f689a56..75d7ab5eb 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -356,3 +356,12 @@ const NON_IMPORTABLE_KINDS = new Set([ export function isImportableKind(kind: Node['kind']): boolean { return !NON_IMPORTABLE_KINDS.has(kind); } + +/** + * The signature extraction gives a C/C++ `constant` minted from a + * function-like `preproc_function_def` (`#define NAME(args) …`, #1838). A + * macro is a value: it is never a `calls` target, and its presence in a + * translation unit is what makes `NAME(x)` a macro expansion rather than a + * call. + */ +export const CPP_DEFINE_SIGNATURE = /^\s*#\s*define\b/;