diff --git a/gitnexus/src/core/ingestion/languages/c/captures.ts b/gitnexus/src/core/ingestion/languages/c/captures.ts index 1ee52030f..751d645f7 100644 --- a/gitnexus/src/core/ingestion/languages/c/captures.ts +++ b/gitnexus/src/core/ingestion/languages/c/captures.ts @@ -5,6 +5,7 @@ import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { splitCInclude } from './import-decomposer.js'; import { computeCDeclarationArity, computeCCallArity } from './arity-metadata.js'; +import { markStaticName } from './static-linkage.js'; export function emitCScopeCaptures( sourceText: string, @@ -62,7 +63,7 @@ export function emitCScopeCaptures( if (structTypedefRanges.has(key)) continue; } - // Enrich function declarations with arity metadata + // Enrich function declarations with arity metadata and detect static linkage const declAnchor = grouped['@declaration.function']; if (declAnchor !== undefined) { const fnNode = @@ -91,6 +92,14 @@ export function emitCScopeCaptures( JSON.stringify(arity.parameterTypes), ); } + + // Detect static storage class (file-local linkage) + if (hasStaticStorageClass(fnNode)) { + const nameText = grouped['@declaration.name']?.text; + if (nameText !== undefined) { + markStaticName(_filePath, nameText); + } + } } } @@ -112,3 +121,17 @@ export function emitCScopeCaptures( return out; } + +/** + * Check if a C function_definition or declaration has `static` storage class. + * Walks direct children for a `storage_class_specifier` node with text `static`. + */ +function hasStaticStorageClass(node: ReturnType['parse']>['rootNode']): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child !== null && child.type === 'storage_class_specifier' && child.text === 'static') { + return true; + } + } + return false; +} diff --git a/gitnexus/src/core/ingestion/languages/c/import-target.ts b/gitnexus/src/core/ingestion/languages/c/import-target.ts index 766905984..a90da6216 100644 --- a/gitnexus/src/core/ingestion/languages/c/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/c/import-target.ts @@ -5,6 +5,11 @@ * the workspace. "foo.h" matches "src/foo.h", "include/foo.h", etc. * For paths with directory components ("dir/foo.h"), match the full * relative suffix. + * + * Tie-breaking: prefer the match with the fewest path components + * (closest to root). On equal depth, break ties lexicographically + * by normalized path to ensure deterministic resolution regardless + * of filesystem iteration order. */ export function resolveCImportTarget( targetRaw: string, @@ -22,15 +27,17 @@ export function resolveCImportTarget( const suffix = '/' + normalizedTarget; let bestMatch: string | null = null; let bestDepth = Infinity; + let bestNormalized = ''; for (const filePath of allFilePaths) { const normalized = filePath.replace(/\\/g, '/'); if (normalized === normalizedTarget || normalized.endsWith(suffix)) { // Prefer shortest path (closest match) const depth = normalized.split('/').length; - if (depth < bestDepth) { + if (depth < bestDepth || (depth === bestDepth && normalized < bestNormalized)) { bestDepth = depth; bestMatch = filePath; + bestNormalized = normalized; } } } diff --git a/gitnexus/src/core/ingestion/languages/c/index.ts b/gitnexus/src/core/ingestion/languages/c/index.ts index 7dc0dca82..d70d79a0c 100644 --- a/gitnexus/src/core/ingestion/languages/c/index.ts +++ b/gitnexus/src/core/ingestion/languages/c/index.ts @@ -8,3 +8,4 @@ export { cArityCompatibility } from './arity.js'; export { cMergeBindings } from './merge-bindings.js'; export { cBindingScopeFor, cImportOwningScope, cReceiverBinding } from './simple-hooks.js'; export { resolveCImportTarget } from './import-target.js'; +export { markStaticName, isStaticName, clearStaticNames, expandCWildcardNames } from './static-linkage.js'; diff --git a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts index f4d1a156e..da412a99a 100644 --- a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts @@ -6,6 +6,7 @@ import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolv import { cProvider } from '../c-cpp.js'; import { cArityCompatibility, cMergeBindings, resolveCImportTarget } from './index.js'; import { scanHeaderFiles } from './header-scan.js'; +import { expandCWildcardNames } from './static-linkage.js'; /** * C `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by @@ -38,6 +39,9 @@ export const cScopeResolver: ScopeResolver = { return resolveCImportTarget(targetRaw, fromFile, allFilePaths); }, + expandsWildcardTo: (targetModuleScope, parsedFiles) => + expandCWildcardNames(targetModuleScope, parsedFiles), + mergeBindings: (existing, incoming, scopeId) => cMergeBindings(existing, incoming, scopeId), arityCompatibility: (callsite, def) => cArityCompatibility(def, callsite), diff --git a/gitnexus/src/core/ingestion/languages/c/static-linkage.ts b/gitnexus/src/core/ingestion/languages/c/static-linkage.ts new file mode 100644 index 000000000..babe1fd17 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/static-linkage.ts @@ -0,0 +1,56 @@ +import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; + +/** + * Per-file set of function names declared with `static` storage class. + * Populated during `emitCScopeCaptures` and consumed by `expandCWildcardNames` + * to exclude file-local symbols from cross-file wildcard import visibility. + * + * Key: filePath, Value: Set of static function names. + */ +const staticNames = new Map>(); + +/** Record a symbol name as `static` (file-local linkage) for the given file. */ +export function markStaticName(filePath: string, name: string): void { + let names = staticNames.get(filePath); + if (names === undefined) { + names = new Set(); + staticNames.set(filePath, names); + } + names.add(name); +} + +/** Check whether a symbol name has `static` linkage in the given file. */ +export function isStaticName(filePath: string, name: string): boolean { + return staticNames.get(filePath)?.has(name) ?? false; +} + +/** Clear tracked static names (for testing). */ +export function clearStaticNames(): void { + staticNames.clear(); +} + +/** + * Return the names visible through a C wildcard import (`#include`). + * All module-scope defs from the target file are visible EXCEPT those + * declared with `static` storage class (file-local linkage in C). + */ +export function expandCWildcardNames( + targetModuleScope: ScopeId, + parsedFiles: readonly ParsedFile[], +): readonly string[] { + const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope); + if (target === undefined) return []; + + const names: string[] = []; + for (const def of target.localDefs) { + const name = simpleName(def); + if (name === '') continue; + if (isStaticName(target.filePath, name)) continue; + if (!names.includes(name)) names.push(name); + } + return names; +} + +function simpleName(def: SymbolDefinition): string { + return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; +} diff --git a/gitnexus/test/unit/scope-resolution/c/c-arity.test.ts b/gitnexus/test/unit/scope-resolution/c/c-arity.test.ts new file mode 100644 index 000000000..36a3ba4a8 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/c/c-arity.test.ts @@ -0,0 +1,178 @@ +/** + * Unit tests for C arity computation and compatibility. + */ + +import { describe, it, expect } from 'vitest'; +import { getCParser } from '../../../../src/core/ingestion/languages/c/query.js'; +import { + computeCDeclarationArity, + computeCCallArity, +} from '../../../../src/core/ingestion/languages/c/arity-metadata.js'; +import { cArityCompatibility } from '../../../../src/core/ingestion/languages/c/arity.js'; +import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js'; +import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; + +function parseFunctionNode(src: string): SyntaxNode | null { + const tree = getCParser().parse(src); + for (let i = 0; i < tree.rootNode.namedChildCount; i++) { + const child = tree.rootNode.namedChild(i); + if (child?.type === 'function_definition' || child?.type === 'declaration') { + return child as SyntaxNode; + } + } + return null; +} + +function parseCallNode(src: string): SyntaxNode | null { + const tree = getCParser().parse(src); + // Walk deeper to find call_expression + function findCall(node: SyntaxNode): SyntaxNode | null { + if (node.type === 'call_expression') return node; + for (let i = 0; i < node.namedChildCount; i++) { + const found = findCall(node.namedChild(i) as SyntaxNode); + if (found !== null) return found; + } + return null; + } + return findCall(tree.rootNode as SyntaxNode); +} + +describe('computeCDeclarationArity', () => { + it('returns count for simple parameters', () => { + const node = parseFunctionNode('int add(int a, int b) { return a + b; }'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + expect(arity.parameterCount).toBe(2); + expect(arity.requiredParameterCount).toBe(2); + }); + + it('returns zero for (void) parameter list', () => { + const node = parseFunctionNode('void f(void) { }'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + expect(arity.parameterCount).toBe(0); + expect(arity.requiredParameterCount).toBe(0); + expect(arity.parameterTypes).toEqual([]); + }); + + it('handles variadic functions — parameterCount is undefined', () => { + const node = parseFunctionNode('int printf(const char *fmt, ...) { return 0; }'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + expect(arity.parameterCount).toBeUndefined(); + expect(arity.requiredParameterCount).toBe(1); + expect(arity.parameterTypes).toContain('...'); + }); + + it('extracts parameter types', () => { + const node = parseFunctionNode('void f(int a, float b, char *c) { }'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + expect(arity.parameterTypes).toEqual(['int', 'float', 'char']); + }); + + it('handles pointer-return function', () => { + const node = parseFunctionNode('int *create(int size) { return 0; }'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + expect(arity.parameterCount).toBe(1); + }); + + it('handles function prototype (no body)', () => { + const node = parseFunctionNode('int add(int a, int b);'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + expect(arity.parameterCount).toBe(2); + }); + + it('returns empty for non-function node', () => { + const node = parseFunctionNode('int x = 5;'); + // This might be a declaration node, but without function_declarator + if (node !== null) { + const arity = computeCDeclarationArity(node); + expect(arity.parameterCount).toBeUndefined(); + } + }); + + it('handles single parameter', () => { + const node = parseFunctionNode('void f(int x) { }'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + expect(arity.parameterCount).toBe(1); + expect(arity.requiredParameterCount).toBe(1); + }); +}); + +describe('computeCCallArity', () => { + it('counts zero arguments', () => { + const node = parseCallNode('void f(void) { init(); }'); + expect(node).not.toBeNull(); + expect(computeCCallArity(node!)).toBe(0); + }); + + it('counts two arguments', () => { + const node = parseCallNode('void f(void) { add(1, 2); }'); + expect(node).not.toBeNull(); + expect(computeCCallArity(node!)).toBe(2); + }); + + it('counts three arguments', () => { + const node = parseCallNode('void f(void) { func(a, b, c); }'); + expect(node).not.toBeNull(); + expect(computeCCallArity(node!)).toBe(3); + }); + + it('counts string literal arguments', () => { + const node = parseCallNode('void f(void) { printf("hello %s", name); }'); + expect(node).not.toBeNull(); + expect(computeCCallArity(node!)).toBe(2); + }); +}); + +describe('cArityCompatibility', () => { + function makeDef(params: Partial): SymbolDefinition { + return { + nodeId: 'test', + filePath: 'test.c', + type: 'Function', + ...params, + }; + } + + function makeCallsite(arity: number): Callsite { + return { arity } as Callsite; + } + + it('returns compatible for exact match', () => { + const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); + expect(cArityCompatibility(def, makeCallsite(2))).toBe('compatible'); + }); + + it('returns incompatible for too few args', () => { + const def = makeDef({ parameterCount: 3, requiredParameterCount: 3 }); + expect(cArityCompatibility(def, makeCallsite(1))).toBe('incompatible'); + }); + + it('returns incompatible for too many args (non-variadic)', () => { + const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); + expect(cArityCompatibility(def, makeCallsite(5))).toBe('incompatible'); + }); + + it('returns compatible for variadic with enough args', () => { + const def = makeDef({ + requiredParameterCount: 1, + parameterTypes: ['const char *', '...'], + }); + expect(cArityCompatibility(def, makeCallsite(3))).toBe('compatible'); + }); + + it('returns unknown when no arity info on def', () => { + const def = makeDef({}); + expect(cArityCompatibility(def, makeCallsite(2))).toBe('unknown'); + }); + + it('returns unknown for negative callsite arity', () => { + const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); + expect(cArityCompatibility(def, makeCallsite(-1))).toBe('unknown'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/c/c-captures.test.ts b/gitnexus/test/unit/scope-resolution/c/c-captures.test.ts new file mode 100644 index 000000000..c5391d795 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/c/c-captures.test.ts @@ -0,0 +1,351 @@ +/** + * 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 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 ', (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); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/c/c-imports.test.ts b/gitnexus/test/unit/scope-resolution/c/c-imports.test.ts new file mode 100644 index 000000000..c883e8289 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/c/c-imports.test.ts @@ -0,0 +1,139 @@ +/** + * Unit tests for C import decomposition, interpretation, and target resolution. + */ + +import { describe, it, expect } from 'vitest'; +import { getCParser } from '../../../../src/core/ingestion/languages/c/query.js'; +import { splitCInclude } from '../../../../src/core/ingestion/languages/c/import-decomposer.js'; +import { interpretCImport } from '../../../../src/core/ingestion/languages/c/interpret.js'; +import { resolveCImportTarget } from '../../../../src/core/ingestion/languages/c/import-target.js'; +import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js'; + +function parseIncludeNode(src: string): SyntaxNode | null { + const tree = getCParser().parse(src); + for (let i = 0; i < tree.rootNode.namedChildCount; i++) { + const child = tree.rootNode.namedChild(i); + if (child?.type === 'preproc_include') return child as SyntaxNode; + } + return null; +} + +function capt(name: string, text: string) { + return { name, text, range: { startLine: 1, startCol: 1, endLine: 1, endCol: 1 } }; +} + +describe('C import decomposition (splitCInclude)', () => { + it('decomposes local include "#include \\"foo.h\\""', () => { + const node = parseIncludeNode('#include "foo.h"'); + expect(node).not.toBeNull(); + const match = splitCInclude(node!); + expect(match).not.toBeNull(); + expect(match!['@import.source'].text).toBe('foo.h'); + expect(match!['@import.kind'].text).toBe('wildcard'); + expect(match!['@import.system']).toBeUndefined(); + }); + + it('decomposes system include "#include "', () => { + const node = parseIncludeNode('#include '); + expect(node).not.toBeNull(); + const match = splitCInclude(node!); + expect(match).not.toBeNull(); + expect(match!['@import.source'].text).toBe('stdio.h'); + expect(match!['@import.system']).toBeDefined(); + }); + + it('decomposes nested path include', () => { + const node = parseIncludeNode('#include "utils/helpers.h"'); + expect(node).not.toBeNull(); + const match = splitCInclude(node!); + expect(match).not.toBeNull(); + expect(match!['@import.source'].text).toBe('utils/helpers.h'); + }); +}); + +describe('C import interpretation (interpretCImport)', () => { + it('interprets local include as wildcard import', () => { + const result = interpretCImport({ + '@import.kind': capt('@import.kind', 'wildcard'), + '@import.source': capt('@import.source', 'header.h'), + }); + expect(result).toEqual({ kind: 'wildcard', targetRaw: 'header.h' }); + }); + + it('returns null for system headers', () => { + const result = interpretCImport({ + '@import.kind': capt('@import.kind', 'wildcard'), + '@import.source': capt('@import.source', 'stdio.h'), + '@import.system': capt('@import.system', 'true'), + }); + expect(result).toBeNull(); + }); + + it('returns null when @import.source is missing', () => { + const result = interpretCImport({ + '@import.kind': capt('@import.kind', 'wildcard'), + }); + expect(result).toBeNull(); + }); +}); + +describe('C import target resolution (resolveCImportTarget)', () => { + it('resolves exact match', () => { + const result = resolveCImportTarget('foo.h', 'main.c', new Set(['foo.h', 'bar.h'])); + expect(result).toBe('foo.h'); + }); + + it('resolves suffix match to shortest path', () => { + const result = resolveCImportTarget( + 'foo.h', + 'main.c', + new Set(['src/include/foo.h', 'include/foo.h', 'other/bar.h']), + ); + expect(result).toBe('include/foo.h'); + }); + + it('resolves nested include path with directory components', () => { + const result = resolveCImportTarget( + 'utils/helpers.h', + 'main.c', + new Set(['src/utils/helpers.h', 'lib/utils/helpers.h']), + ); + // Both have same depth (3 components), so lexicographic tiebreak picks lib/ + expect(result).toBe('lib/utils/helpers.h'); + }); + + it('returns null for empty target', () => { + expect(resolveCImportTarget('', 'main.c', new Set(['foo.h']))).toBeNull(); + }); + + it('returns null when no match found', () => { + expect(resolveCImportTarget('missing.h', 'main.c', new Set(['foo.h']))).toBeNull(); + }); + + it('is deterministic on depth ties — lexicographic tiebreak', () => { + const files = new Set(['test/util/foo.h', 'src/util/foo.h']); + const result1 = resolveCImportTarget('foo.h', 'main.c', files); + const result2 = resolveCImportTarget('foo.h', 'main.c', files); + expect(result1).toBe(result2); + // Lexicographic: src/util/foo.h < test/util/foo.h + expect(result1).toBe('src/util/foo.h'); + }); + + it('prefers shallower path over lexicographic order', () => { + const result = resolveCImportTarget( + 'foo.h', + 'main.c', + new Set(['a/b/c/foo.h', 'z/foo.h']), + ); + expect(result).toBe('z/foo.h'); + }); + + it('handles backslash paths (Windows)', () => { + const result = resolveCImportTarget( + 'foo.h', + 'main.c', + new Set(['include\\foo.h']), + ); + expect(result).toBe('include\\foo.h'); + }); +});