mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-02 09:45:39 +08:00
* 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/<arity>`; 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 <noreply@anthropic.com> * 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 <danusha2345@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: danusha2345 <danusha2345@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
281 lines
12 KiB
TypeScript
281 lines
12 KiB
TypeScript
/**
|
|
* Kernel↔wasm C/C++ extraction parity (R7a of the kernel migration).
|
|
*
|
|
* Asserts the native walker (codegraph-kernel/src/ccpp/) produces the SAME
|
|
* ExtractionResult as the wasm TreeSitterExtractor — nodes, edges, and
|
|
* unresolved refs compared as canonicalized multisets — over:
|
|
* - the checked-in torture fixtures (torture.c / torture.cpp / torture.hpp:
|
|
* fn-ptr tables, typedef enum/struct, multi-declarator consts, namespaces
|
|
* incl. C++17 nested, out-of-line Cls::method defs, templates + template
|
|
* bases, operators, stack construction, local fn-ptrs, UE-macro shapes
|
|
* through the hoisted preParse, using-aliases, value-ref shadowing), and
|
|
* - Metal/CUDA-shaped sources arriving as language 'cpp' — pinning that the
|
|
* route point applies the SAME extension/content-gated preParse blanks to
|
|
* the kernel arm (docs/design/ccpp-kernel-port-checklist.md, decision 1/2).
|
|
*
|
|
* Files with parse errors — including the spaced explicit-operator CALL-SITE
|
|
* shape (#1247), which rides an ERROR node — must DEFER to wasm (`defer:`),
|
|
* asserted below. The full-repo sweep lives in scripts/kernel-parity.mjs
|
|
* (redis/git/fmt et al., run for the §5 gate); this suite keeps the invariant
|
|
* alive in `npm test`. Skips when no kernel binary is staged;
|
|
* CODEGRAPH_KERNEL_EXPECT=1 turns that into a failure (kernel-scaffold.test.ts).
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { extractFromSource } from '../src/extraction';
|
|
import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
|
|
import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel';
|
|
import type { ExtractionResult, Language } from '../src/types';
|
|
|
|
const KERNEL_PATH = path.join(
|
|
__dirname,
|
|
'..',
|
|
'codegraph-kernel',
|
|
'prebuilds',
|
|
`${process.platform}-${process.arch}`,
|
|
'codegraph-kernel.node'
|
|
);
|
|
const kernelBuilt = fs.existsSync(KERNEL_PATH);
|
|
|
|
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'kernel-parity');
|
|
|
|
function canon(result: ExtractionResult): { nodes: string[]; edges: string[]; refs: string[] } {
|
|
return {
|
|
nodes: result.nodes
|
|
.map(({ updatedAt: _u, ...n }) => JSON.stringify(n, Object.keys(n).sort()))
|
|
.sort(),
|
|
edges: result.edges.map((e) => JSON.stringify(e, Object.keys(e).sort())).sort(),
|
|
refs: result.unresolvedReferences
|
|
.map((r) => JSON.stringify(r, Object.keys(r).sort()))
|
|
.sort(),
|
|
};
|
|
}
|
|
|
|
const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const;
|
|
let savedEnv: Record<string, string | undefined>;
|
|
|
|
describe.skipIf(!kernelBuilt)('kernel C/C++ extraction parity', () => {
|
|
beforeAll(async () => {
|
|
await initGrammars();
|
|
await loadGrammarsForLanguages(['c', 'cpp']);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
resetKernelForTests();
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const k of ENV_KEYS) {
|
|
if (savedEnv[k] === undefined) delete process.env[k];
|
|
else process.env[k] = savedEnv[k];
|
|
}
|
|
resetKernelForTests();
|
|
});
|
|
|
|
function assertParity(filePath: string, source: string, language: Language, minNodes = 3): void {
|
|
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
const viaKernel = tryKernelExtract(filePath, source, language);
|
|
expect(viaKernel, `kernel extraction failed for ${filePath}`).not.toBeNull();
|
|
|
|
process.env.CODEGRAPH_KERNEL = '0';
|
|
const viaWasm = extractFromSource(filePath, source, language);
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
|
|
const k = canon(viaKernel!);
|
|
const w = canon(viaWasm);
|
|
expect(k.nodes, `${filePath}: nodes`).toEqual(w.nodes);
|
|
expect(k.edges, `${filePath}: edges`).toEqual(w.edges);
|
|
expect(k.refs, `${filePath}: refs`).toEqual(w.refs);
|
|
// Meaningful comparison, not empty-vs-empty (the inline Metal/CUDA
|
|
// sources are deliberately small — they pass their exact node count).
|
|
expect(viaWasm.nodes.length).toBeGreaterThanOrEqual(minNodes);
|
|
}
|
|
|
|
it.each(['c', 'cpp'] as const)('single-argument function macros and negative controls: %s (#1373)', (language) => {
|
|
const source = [
|
|
'#define NATIVE_FN(name) int name(void)',
|
|
'NATIVE_FN(get_version) { return helper(); }',
|
|
'int use_it(void) { return get_version(); }',
|
|
'#define FN(name) int name(void)',
|
|
'FN(short_macro) { return 0; }',
|
|
'#define POINTER_FN(name) const char *name(void)',
|
|
'POINTER_FN(get_text) { return 0; }',
|
|
'#define TEST_CASE(name) int test_ ## name(void)',
|
|
'TEST_CASE(candidate) { return 0; }',
|
|
'#define REGISTER_FN(name) register_test(name)',
|
|
'REGISTER_FN(registration) { return 0; }',
|
|
'int (parenthesized)(void) { return 1; }',
|
|
'',
|
|
].join('\n');
|
|
assertParity(`fixtures/macros.${language}`, source, language);
|
|
assertParity(`fixtures/macros-crlf.${language}`, source.replace(/\n/g, '\r\n'), language);
|
|
const result = tryKernelExtract(`fixtures/macros.${language}`, source, language)!;
|
|
const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
|
|
expect(names).toContain('get_version');
|
|
expect(names).toContain('get_text');
|
|
expect(names).toContain('short_macro');
|
|
expect(names).not.toContain('candidate');
|
|
expect(names).not.toContain('registration');
|
|
});
|
|
|
|
it.each(['\n', '\r\n'])('COM interface declarations retain native/wasm parity (%j)', (eol) => {
|
|
const source = [
|
|
'#define interface struct',
|
|
'struct IParentInterface { virtual void Parent() = 0; };',
|
|
'interface IMyComInterface : IParentInterface {',
|
|
' virtual void Foo() = 0;',
|
|
' virtual void Bar() = 0;',
|
|
'};',
|
|
'interface IStandalone { virtual void Run() = 0; };',
|
|
'',
|
|
].join(eol);
|
|
assertParity('MyInterface.h', source, 'cpp', 8);
|
|
});
|
|
|
|
it('torture fixture (c): fn-ptr tables, typedefs, file-scope consts, value-refs', () => {
|
|
const file = path.join(FIXTURE_DIR, 'torture.c');
|
|
assertParity('fixtures/torture.c', fs.readFileSync(file, 'utf8'), 'c');
|
|
});
|
|
|
|
it('torture fixture (cpp): namespaces, out-of-line methods, templates, fn-ptrs, UE macros', () => {
|
|
const file = path.join(FIXTURE_DIR, 'torture.cpp');
|
|
assertParity('fixtures/torture.cpp', fs.readFileSync(file, 'utf8'), 'cpp');
|
|
});
|
|
|
|
it('torture fixture (hpp): fwd decls, extern "C", header templates, reflection markup', () => {
|
|
const file = path.join(FIXTURE_DIR, 'torture.hpp');
|
|
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).
|
|
it('metal-shaped source (.metal → cpp): attribute blanks applied on both arms', () => {
|
|
const metal = [
|
|
'struct VertexIn {',
|
|
' float3 position [[attribute(0)]];',
|
|
' float2 uv [[attribute(1)]];',
|
|
'};',
|
|
'static float2 scale_uv(float2 uv) { return uv; }',
|
|
'',
|
|
].join('\n');
|
|
assertParity('fixtures/shader.metal', metal, 'cpp');
|
|
});
|
|
|
|
// CUDA rides the cpp route too: specifier + launch-config blanks are gated
|
|
// by extension OR content, and both fire before the kernel call.
|
|
it('cuda-shaped source (.cu → cpp): specifier + launch blanks applied on both arms', () => {
|
|
const cuda = [
|
|
'__global__ void step_kernel(float *data) { data[0] += 1.0f; }',
|
|
'void launch(float *data) { step_kernel<<<1, 256>>>(data); }',
|
|
'',
|
|
].join('\n');
|
|
assertParity('fixtures/kern.cu', cuda, 'cpp');
|
|
});
|
|
|
|
// Every torture fixture again with CRLF line endings — the shape every
|
|
// Windows autocrlf checkout has. Derived in memory (not a checked-in CRLF
|
|
// file) so no platform or editor can silently normalize it away. Pins the
|
|
// JS-multiline-^ docstring semantics for the C comment markers (#1329).
|
|
it.each([
|
|
['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(/(?<!\r)\n/g, '\r\n');
|
|
assertParity(`fixtures/${name} (crlf)`, crlf, lang);
|
|
});
|
|
|
|
it('spaced explicit-operator call sites defer to the wasm extractor (#1247 rides an ERROR node)', () => {
|
|
const source = [
|
|
'struct It { int operator*() const { return 1; } };',
|
|
'int read_it(const It &it) { return it.operator *(); }',
|
|
'',
|
|
].join('\n');
|
|
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
expect(tryKernelExtract('src/op.cpp', source, 'cpp')).toBeNull();
|
|
// The seam still serves the file — through the wasm path, where the
|
|
// operator-call recovery emits the `it.operator*` ref.
|
|
process.env.CODEGRAPH_KERNEL = '0';
|
|
const viaWasm = extractFromSource('src/op.cpp', source, 'cpp');
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
expect(
|
|
viaWasm.unresolvedReferences.some((r) => r.referenceName === 'it.operator*')
|
|
).toBe(true);
|
|
});
|
|
|
|
it('files with parse errors defer to the wasm extractor (recovery is encoding-dependent)', () => {
|
|
const broken = 'void f( {\n return }} 12 (\n';
|
|
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
expect(tryKernelExtract('src/broken.c', broken, 'c')).toBeNull();
|
|
process.env.CODEGRAPH_KERNEL = '0';
|
|
const viaWasm = extractFromSource('src/broken.c', broken, 'c');
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
expect(viaWasm.nodes.some((n) => n.kind === 'file')).toBe(true);
|
|
});
|
|
});
|