GitNexus/gitnexus/test/unit/scope-resolution/c/c-captures.test.ts
Copilot e412d292fe
feat: migrate C to scope-based resolution (RFC #909 Ring 3) (#1481)
* Initial plan

* feat: add C scope resolution files for language migration (RFC #909)

Add 11 C language scope resolution files following the Go pattern:
- query.ts: tree-sitter-c query and parser for C constructs
- captures.ts: emit scope captures with arity enrichment
- import-decomposer.ts: decompose #include into structured captures
- arity-metadata.ts: C function declaration/call arity computation
- interpret.ts: interpret C imports and type bindings
- import-target.ts: resolve #include paths via suffix matching
- arity.ts: C arity compatibility (variadic detection)
- merge-bindings.ts: first-wins binding merge by tier
- simple-hooks.ts: null hooks (no receivers/methods in C)
- index.ts: barrel re-exports
- scope-resolver.ts: ScopeResolver implementation for C

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* feat: migrate C to scope-based resolution (RFC #909 Ring 3)

Add C ScopeResolver with:
- tree-sitter-c scope query (structs, unions, enums, functions, macros, variables, includes)
- emitCScopeCaptures with arity enrichment and typedef-struct dedup
- interpretCImport for #include directives (system headers filtered)
- resolveCImportTarget with suffix matching
- cArityCompatibility with variadic detection
- cMergeBindings (first-wins by tier)
- Header file scanning for cross-language #include resolution
- Register in SCOPE_RESOLVERS and MIGRATED_LANGUAGES
- Integration test with 4 passing test cases

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/ddcbc075-2999-492c-a0ac-47cddd401a4b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: update registry-primary-flag test and add C legacy parity expected failures

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/ddcbc075-2999-492c-a0ac-47cddd401a4b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refactor: improve arity-metadata readability per review feedback

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/ddcbc075-2999-492c-a0ac-47cddd401a4b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address CI failures — unused import, Dirent types, null comparison, lint, formatting

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/f4b6e20d-8d56-4834-8296-db82af27f8e1

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: replace loose comparisons with strict equality, remove optional chaining from childForFieldName

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/20628629-9dc7-45ec-8ae4-f3a14ad29d93

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* Potential fix for pull request finding 'CodeQL / Comparison between inconvertible types'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: remove unnecessary optional chaining on non-null decl in findFuncDeclarator

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9b012c35-7494-4180-8c6a-b83da8d8abb9

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address 5 production readiness review findings

Finding 1: Enforce static functions as file-local via expandsWildcardTo hook
- Add static-linkage.ts tracking module with markStaticName/isStaticName/expandCWildcardNames
- Update captures.ts to detect storage_class_specifier static on functions
- Wire expandsWildcardTo in scope-resolver.ts

Finding 2: Expand test coverage to ≥30 cases (74 unit tests added)
- c-captures.test.ts: 55 tests (scopes, structs, unions, enums, functions, typedef, field, variable, macro, imports, references, type bindings, arity, static)
- c-imports.test.ts: 12 tests (decomposition, interpretation, target resolution, determinism, edge cases)
- c-arity.test.ts: 18 tests (declaration arity, call arity, compatibility)

Finding 3: Deterministic #include resolution on depth ties
- Add lexicographic tiebreak in import-target.ts when candidates tie on path depth

Finding 4: Revert unexplained package-lock.json change
- Restored to pre-PR state (node >=20.0.0)

Finding 5: Planning artifact commit acknowledged (squash on merge)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/22ee780c-2b44-4e69-b9c4-8843ad6ec1ee

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address code review feedback — Set-based dedup, SyntaxNode type alias

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/22ee780c-2b44-4e69-b9c4-8843ad6ec1ee

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix: address second review findings 1-4 — isFileLocalDef hook, singleton docs, fn-ptr typedef, static isolation test

Finding 1: Added `isFileLocalDef` hook to ScopeResolver contract + implementation
in free-call-fallback.ts to filter C static functions from global free-call
fallback. Threads caller filePath through pickUniqueGlobalCallable so static
defs in other files are excluded.

Finding 2: Documented single-invocation assumption on staticNames Map. Added
clearStaticNames() call in loadResolutionConfig to prevent cross-repo
contamination in server-mode scenarios.

Finding 3: Added tree-sitter query pattern for function pointer typedef aliases
(typedef void (*callback)(int, int)) in query.ts. Added unit test.

Finding 4: Added c-static-isolation integration fixture (a.c with static helper,
b.c with non-static helper, caller.c) and test asserting no CALLS edge from
caller to a.c's static helper.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/5b948327-9f59-4ca2-9d8c-8c8087feb510

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix: skip static isolation integration test in legacy parity mode

The `caller.c calls b:helper via include, NOT a:static helper` test
requires scope-based wildcard import binding + isFileLocalDef filtering
which is only available in the registry-primary path. The legacy DAG
path does not resolve cross-file calls through #include → prototype
chains. Added to LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3be48249-b375-4446-973b-657400f530fb

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address 3 review findings — static leakage in Phase 2, build-dir skip list, same-directory preference

Finding 1: Apply isFileLocalDef filtering in Phase 2 of pickUniqueGlobalCallable
so cross-file static defs cannot leak through the SemanticModel fallback path.

Finding 2: Expand scanHeaderFiles skip list with dist, build, out, target,
_build, .next, cmake-build-* to avoid generated headers shadowing source ones.

Finding 3: Implement same-directory sibling preference in resolveCImportTarget,
matching C compiler #include "…" relative-lookup semantics. Sibling check now
runs before exact match and suffix fallback.

Tests: 11 new header-scan tests, 4 new import-target tests (96 total C tests).

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/5236f2b8-72a0-476d-bf39-cca041781014

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refactor: skip redundant sibling path computation when targetRaw has no backslashes

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/5236f2b8-72a0-476d-bf39-cca041781014

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix: normalize header-scan paths to forward slashes for Windows compatibility

On Windows, path.relative() produces backslash-separated paths (e.g. src\foo.h)
which caused test failures when comparing against forward-slash expectations.
Normalize output with .replace(/\\/g, '/') matching the convention used
throughout the scope-resolution pipeline.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/88420759-8a8c-4e6d-affa-ecb5458a235a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address 4 findings — K&R arity, function-pointer docs, prototype docs, _filePath rename

Finding 1: K&R empty parameter list `int foo()` now returns unknown arity `{}`
instead of `{parameterCount:0}`. Distinguishes from explicit `int foo(void)`.
3 unit tests cover K&R definition, prototype, and void comparison.

Finding 2: Added code comment documenting function-pointer-variable call
capture as known architectural trade-off (same as Go resolver).

Finding 3: Added code comment documenting prototype/definition duplication
as graph-quality concern (no false CALLS edges).

Finding 4: Renamed `_filePath` → `filePath` in captures.ts since it is
actively used in markStaticName().

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/ab2164f7-972f-4ea8-81fa-a14ce20d7cce

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-11 09:02:00 +01:00

355 lines
13 KiB
TypeScript

/**
* Unit tests for C scope query + captures orchestrator.
*
* Pins the capture-tag vocabulary + range shape for every construct
* the scope-resolution pipeline reads. Runs against tree-sitter-c
* so it catches grammar drift before the integration parity gate does.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { emitCScopeCaptures } from '../../../../src/core/ingestion/languages/c/captures.js';
import {
clearStaticNames,
isStaticName,
} from '../../../../src/core/ingestion/languages/c/static-linkage.js';
function tagsFor(src: string, filePath = 'test.c'): string[][] {
const matches = emitCScopeCaptures(src, filePath);
return matches.map((m) => Object.keys(m).sort());
}
function findMatch(src: string, predicate: (tags: string[]) => boolean, filePath = 'test.c') {
const matches = emitCScopeCaptures(src, filePath);
return matches.find((m) => predicate(Object.keys(m)));
}
function allMatches(src: string, predicate: (tags: string[]) => boolean, filePath = 'test.c') {
const matches = emitCScopeCaptures(src, filePath);
return matches.filter((m) => predicate(Object.keys(m)));
}
describe('emitCScopeCaptures — scopes', () => {
it('captures translation_unit as @scope.module', () => {
const all = tagsFor('int x = 1;');
expect(all.some((t) => t.includes('@scope.module'))).toBe(true);
});
it('captures struct_specifier as @scope.class', () => {
const all = tagsFor('struct Point { int x; int y; };');
expect(all.some((t) => t.includes('@scope.class'))).toBe(true);
});
it('captures union_specifier as @scope.class', () => {
const all = tagsFor('union Data { int i; float f; };');
expect(all.some((t) => t.includes('@scope.class'))).toBe(true);
});
it('captures function_definition as @scope.function', () => {
const all = tagsFor('void foo(void) { }');
expect(all.some((t) => t.includes('@scope.function'))).toBe(true);
});
it('captures block-level scopes (if, for, while, do, switch, case)', () => {
const src = `
void f(void) {
if (1) { }
for (;;) { }
while (1) { }
do { } while (0);
switch (0) { case 0: break; }
}
`;
const all = tagsFor(src);
const blocks = all.filter((t) => t.includes('@scope.block'));
// compound_statement + if + for + while + do + switch + case = at least 6 blocks
expect(blocks.length).toBeGreaterThanOrEqual(6);
});
});
describe('emitCScopeCaptures — struct declarations', () => {
it('captures named struct with @declaration.struct', () => {
const m = findMatch('struct User { int age; };', (t) => t.includes('@declaration.struct'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('User');
});
it('captures typedef struct with @declaration.struct (not typedef)', () => {
const m = findMatch('typedef struct { int age; } User;', (t) =>
t.includes('@declaration.struct'),
);
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('User');
});
it('suppresses @declaration.typedef when struct already captured same range', () => {
const matches = emitCScopeCaptures('typedef struct { int age; } User;', 'test.c');
const typedefs = matches.filter((m) => '@declaration.typedef' in m);
expect(typedefs).toHaveLength(0);
});
});
describe('emitCScopeCaptures — union declarations', () => {
it('captures named union with @declaration.union', () => {
const m = findMatch('union Data { int i; float f; };', (t) => t.includes('@declaration.union'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('Data');
});
it('captures typedef union with @declaration.union', () => {
const m = findMatch('typedef union { int i; float f; } Value;', (t) =>
t.includes('@declaration.union'),
);
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('Value');
});
});
describe('emitCScopeCaptures — enum declarations', () => {
it('captures enum with @declaration.enum', () => {
const m = findMatch('enum Color { RED, GREEN, BLUE };', (t) => t.includes('@declaration.enum'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('Color');
});
it('captures enum constants as @declaration.const', () => {
const matches = allMatches('enum Color { RED, GREEN, BLUE };', (t) =>
t.includes('@declaration.const'),
);
const names = matches.map((m) => m['@declaration.name'].text);
expect(names).toContain('RED');
expect(names).toContain('GREEN');
expect(names).toContain('BLUE');
});
});
describe('emitCScopeCaptures — function declarations', () => {
it('captures function definition with @declaration.function', () => {
const m = findMatch('int add(int a, int b) { return a + b; }', (t) =>
t.includes('@declaration.function'),
);
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('add');
});
it('captures function prototype (declaration) with @declaration.function', () => {
const m = findMatch('int add(int a, int b);', (t) => t.includes('@declaration.function'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('add');
});
it('captures pointer-return function definition', () => {
const m = findMatch('int *create(void) { return 0; }', (t) =>
t.includes('@declaration.function'),
);
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('create');
});
it('captures pointer-return function prototype', () => {
const m = findMatch('char *get_name(void);', (t) => t.includes('@declaration.function'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('get_name');
});
});
describe('emitCScopeCaptures — other declarations', () => {
it('captures typedef as @declaration.typedef', () => {
const m = findMatch('typedef int MyInt;', (t) => t.includes('@declaration.typedef'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('MyInt');
});
it('captures function pointer typedef as @declaration.typedef', () => {
const m = findMatch('typedef void (*callback)(int, int);', (t) =>
t.includes('@declaration.typedef'),
);
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('callback');
});
it('captures struct field as @declaration.field', () => {
const m = findMatch('struct P { int x; };', (t) => t.includes('@declaration.field'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('x');
});
it('captures pointer struct field as @declaration.field', () => {
const m = findMatch('struct N { struct N *next; };', (t) => t.includes('@declaration.field'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('next');
});
it('captures variable with initializer as @declaration.variable', () => {
const m = findMatch('int x = 42;', (t) => t.includes('@declaration.variable'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('x');
});
it('captures macro as @declaration.macro', () => {
const m = findMatch('#define MAX 100', (t) => t.includes('@declaration.macro'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('MAX');
});
it('captures function-like macro as @declaration.macro', () => {
const m = findMatch('#define SQUARE(x) ((x) * (x))', (t) => t.includes('@declaration.macro'));
expect(m).toBeDefined();
expect(m!['@declaration.name'].text).toBe('SQUARE');
});
});
describe('emitCScopeCaptures — imports', () => {
it('captures local #include as @import.statement with source', () => {
const m = findMatch('#include "header.h"', (t) => t.includes('@import.statement'));
expect(m).toBeDefined();
expect(m!['@import.source'].text).toBe('header.h');
expect(m!['@import.kind'].text).toBe('wildcard');
});
it('captures system #include with @import.system tag', () => {
const m = findMatch('#include <stdio.h>', (t) => t.includes('@import.statement'));
expect(m).toBeDefined();
expect(m!['@import.system']).toBeDefined();
});
it('captures nested path includes', () => {
const m = findMatch('#include "utils/helpers.h"', (t) => t.includes('@import.statement'));
expect(m).toBeDefined();
expect(m!['@import.source'].text).toBe('utils/helpers.h');
});
});
describe('emitCScopeCaptures — references', () => {
it('captures free call invocations', () => {
const m = findMatch('void f(void) { foo(); }', (t) => t.includes('@reference.call.free'));
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('foo');
});
it('captures member call via pointer (ptr->func())', () => {
const m = findMatch('void f(struct S *s) { s->method(); }', (t) =>
t.includes('@reference.call.member'),
);
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('method');
});
it('captures field reads', () => {
const m = findMatch('void f(struct S *s) { int x = s->field; }', (t) =>
t.includes('@reference.read'),
);
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('field');
});
it('captures field writes (assignment)', () => {
const m = findMatch('void f(struct S *s) { s->field = 1; }', (t) =>
t.includes('@reference.write'),
);
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('field');
});
});
describe('emitCScopeCaptures — type bindings', () => {
it('captures parameter type annotations', () => {
const m = findMatch('void f(int x) { }', (t) => t.includes('@type-binding.parameter'));
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('x');
});
it('captures variable type bindings', () => {
const m = findMatch('void f(void) { int x = 1; }', (t) =>
t.includes('@type-binding.assignment'),
);
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('x');
});
});
describe('emitCScopeCaptures — arity metadata', () => {
it('synthesizes parameter-count on function definitions', () => {
const m = findMatch('int add(int a, int b) { return a + b; }', (t) =>
t.includes('@declaration.parameter-count'),
);
expect(m).toBeDefined();
expect(m!['@declaration.parameter-count'].text).toBe('2');
});
it('synthesizes parameter-types on function definitions', () => {
const m = findMatch('int add(int a, float b) { return 0; }', (t) =>
t.includes('@declaration.parameter-types'),
);
expect(m).toBeDefined();
const types = JSON.parse(m!['@declaration.parameter-types'].text);
expect(types).toEqual(['int', 'float']);
});
it('(void) parameter list yields zero parameters', () => {
const m = findMatch('void f(void) { }', (t) => t.includes('@declaration.function'));
expect(m).toBeDefined();
expect(m!['@declaration.parameter-count'].text).toBe('0');
expect(m!['@declaration.required-parameter-count'].text).toBe('0');
});
it('variadic function has undefined parameter-count but defined required-parameter-count', () => {
const m = findMatch('int printf(const char *fmt, ...) { return 0; }', (t) =>
t.includes('@declaration.function'),
);
expect(m).toBeDefined();
// variadic → parameterCount is undefined (not emitted)
expect(m!['@declaration.parameter-count']).toBeUndefined();
expect(m!['@declaration.required-parameter-count'].text).toBe('1');
});
it('synthesizes arity on call references', () => {
const m = findMatch(
'void f(void) { add(1, 2); }',
(t) => t.includes('@reference.call.free') && t.includes('@reference.arity'),
);
expect(m).toBeDefined();
expect(m!['@reference.arity'].text).toBe('2');
});
it('zero-argument call has arity 0', () => {
const m = findMatch(
'void f(void) { init(); }',
(t) => t.includes('@reference.call.free') && t.includes('@reference.arity'),
);
expect(m).toBeDefined();
expect(m!['@reference.arity'].text).toBe('0');
});
});
describe('emitCScopeCaptures — static storage class', () => {
beforeEach(() => {
clearStaticNames();
});
it('marks static function definitions as file-local', () => {
emitCScopeCaptures('static int helper(void) { return 0; }', 'a.c');
expect(isStaticName('a.c', 'helper')).toBe(true);
});
it('does not mark non-static function definitions as file-local', () => {
emitCScopeCaptures('int helper(void) { return 0; }', 'a.c');
expect(isStaticName('a.c', 'helper')).toBe(false);
});
it('marks static function prototypes as file-local', () => {
emitCScopeCaptures('static int helper(int x);', 'a.c');
expect(isStaticName('a.c', 'helper')).toBe(true);
});
it('static functions are scoped to their file', () => {
emitCScopeCaptures('static int helper(void) { return 0; }', 'a.c');
emitCScopeCaptures('int helper(void) { return 1; }', 'b.c');
expect(isStaticName('a.c', 'helper')).toBe(true);
expect(isStaticName('b.c', 'helper')).toBe(false);
});
it('static pointer-return functions are detected', () => {
emitCScopeCaptures('static char *get_buffer(void) { return 0; }', 'a.c');
expect(isStaticName('a.c', 'get_buffer')).toBe(true);
});
});