diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index dfc5e3c4cc..7aa9dc7bf0 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -63,7 +63,7 @@ "vitest": "^4.0.18" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "optionalDependencies": { "node-addon-api": "^8.0.0", diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 693e8cec2c..7fab4689bc 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -46,6 +46,15 @@ import { createCallExtractor } from '../call-extractors/generic.js'; import { cCallConfig, cppCallConfig } from '../call-extractors/configs/c-cpp.js'; import { createHeritageExtractor } from '../heritage-extractors/generic.js'; import { stripUeMacros } from '../cpp-ue-preprocessor.js'; +import { + emitCScopeCaptures, + interpretCImport, + interpretCTypeBinding, + cArityCompatibility, + cBindingScopeFor, + cImportOwningScope, + cReceiverBinding, +} from './c/index.js'; const C_BUILT_INS: ReadonlySet = new Set([ 'printf', @@ -367,6 +376,16 @@ export const cProvider = defineLanguage({ heritageExtractor: createHeritageExtractor(SupportedLanguages.C), labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, + + // ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ────────── + emitScopeCaptures: emitCScopeCaptures, + interpretImport: interpretCImport, + interpretTypeBinding: interpretCTypeBinding, + bindingScopeFor: cBindingScopeFor, + importOwningScope: cImportOwningScope, + receiverBinding: cReceiverBinding, + arityCompatibility: cArityCompatibility, + // mergeBindings + resolveImportTarget live on ScopeResolver (see c/scope-resolver.ts). }); export const cppProvider = defineLanguage({ diff --git a/gitnexus/src/core/ingestion/languages/c/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/c/arity-metadata.ts new file mode 100644 index 0000000000..013d78ea81 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/arity-metadata.ts @@ -0,0 +1,102 @@ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +export interface CArityInfo { + parameterCount?: number; + requiredParameterCount?: number; + parameterTypes?: string[]; +} + +/** + * Compute declaration arity from a C function definition or declaration node. + */ +export function computeCDeclarationArity(node: SyntaxNode): CArityInfo { + // Find the function_declarator child (may be wrapped in pointer_declarator) + const funcDecl = findFuncDeclarator(node); + if (funcDecl === null) return {}; + + const paramList = funcDecl.childForFieldName('parameters'); + if (paramList === null) return {}; + + const params: SyntaxNode[] = []; + for (let i = 0; i < paramList.childCount; i++) { + const child = paramList.child(i); + if (child === null) continue; + if (child.type === 'parameter_declaration' || child.type === 'variadic_parameter') { + params.push(child); + } + } + + // K&R old-style declaration: `int foo()` has an empty parameter_list with + // no parameter_declaration or variadic_parameter children. Per C89/C99, + // this means the function accepts an unspecified number/types of arguments — + // NOT zero arguments. Return unknown arity to avoid false 'incompatible'. + // `int foo(void)` is the explicit zero-parameter form and is handled below. + if (params.length === 0) return {}; + + // (void) means zero parameters + if (params.length === 1 && params[0].type === 'parameter_declaration') { + const typeNode = params[0].childForFieldName('type'); + const hasDeclarator = params[0].childForFieldName('declarator') !== null; + if (typeNode !== null && typeNode.text === 'void' && !hasDeclarator) { + return { parameterCount: 0, requiredParameterCount: 0, parameterTypes: [] }; + } + } + + const isVariadic = params.some((p) => p.type === 'variadic_parameter'); + const nonVariadicCount = params.filter((p) => p.type !== 'variadic_parameter').length; + + const types: string[] = []; + for (const p of params) { + if (p.type === 'variadic_parameter') { + types.push('...'); + } else { + const typeNode = p.childForFieldName('type'); + types.push(typeNode?.text ?? 'unknown'); + } + } + + return { + parameterCount: isVariadic ? undefined : nonVariadicCount, + requiredParameterCount: nonVariadicCount, + parameterTypes: types, + }; +} + +/** + * Compute call-site arity from a call_expression node. + */ +export function computeCCallArity(node: SyntaxNode): number { + const argList = node.childForFieldName('arguments'); + if (argList === null) return 0; + + let count = 0; + for (let i = 0; i < argList.childCount; i++) { + const child = argList.child(i); + if (child === null) continue; + // Skip punctuation (commas, parens) + if (child.type !== ',' && child.type !== '(' && child.type !== ')') { + count++; + } + } + return count; +} + +function findFuncDeclarator(node: SyntaxNode): SyntaxNode | null { + // Direct child + let decl = node.childForFieldName('declarator'); + if (decl === null) { + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c?.type === 'function_declarator') return c; + } + return null; + } + // Unwrap pointer_declarator + while (decl.type === 'pointer_declarator') { + const next = decl.childForFieldName('declarator'); + if (next === null) break; + decl = next; + } + if (decl.type === 'function_declarator') return decl; + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/c/arity.ts b/gitnexus/src/core/ingestion/languages/c/arity.ts new file mode 100644 index 0000000000..7bbfa9e571 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/arity.ts @@ -0,0 +1,20 @@ +import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; + +/** + * C arity compatibility: no overloading. Variadic functions detected + * via '...' in parameterTypes. Otherwise exact match or unknown. + */ +export function cArityCompatibility( + def: SymbolDefinition, + callsite: Callsite, +): 'compatible' | 'unknown' | 'incompatible' { + const max = def.parameterCount; + const min = def.requiredParameterCount; + if (max === undefined && min === undefined) return 'unknown'; + if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown'; + + const variadic = def.parameterTypes?.some((t) => t === '...') ?? false; + if (min !== undefined && callsite.arity < min) return 'incompatible'; + if (max !== undefined && callsite.arity > max && !variadic) return 'incompatible'; + return 'compatible'; +} diff --git a/gitnexus/src/core/ingestion/languages/c/captures.ts b/gitnexus/src/core/ingestion/languages/c/captures.ts new file mode 100644 index 0000000000..836eb3eac6 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/captures.ts @@ -0,0 +1,142 @@ +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { + findNodeAtRange, + nodeToCapture, + syntheticCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; +import { getCParser, getCScopeQuery } from './query.js'; +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, + filePath: string, + cachedTree?: unknown, +): readonly CaptureMatch[] { + let tree = cachedTree as ReturnType['parse']> | undefined; + if (tree === undefined) { + tree = parseSourceSafe(getCParser(), sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + } + + const rawMatches = getCScopeQuery().matches(tree.rootNode); + const out: CaptureMatch[] = []; + + // Track ranges where typedef-struct/union was captured as @declaration.struct/union + // so we can suppress the duplicate @declaration.typedef match at the same range. + const structTypedefRanges = new Set(); + + for (const m of rawMatches) { + const grouped: Record = {}; + for (const c of m.captures) { + const tag = '@' + c.name; + if (tag.startsWith('@_')) continue; + grouped[tag] = nodeToCapture(tag, c.node); + } + if (Object.keys(grouped).length === 0) continue; + + // Handle #include statements + if (grouped['@import.statement'] !== undefined) { + const anchor = grouped['@import.statement']!; + const includeNode = findNodeAtRange(tree.rootNode, anchor.range, 'preproc_include'); + if (includeNode !== null) { + const split = splitCInclude(includeNode); + if (split !== null) { + out.push(split); + continue; + } + } + } + + // Track typedef-struct ranges to suppress duplicate typedef declarations + const structAnchor = grouped['@declaration.struct'] ?? grouped['@declaration.union']; + if (structAnchor !== undefined) { + const r = structAnchor.range; + structTypedefRanges.add(`${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`); + } + + // Suppress @declaration.typedef if the same range was already captured as struct/union + const typedefAnchor = grouped['@declaration.typedef']; + if (typedefAnchor !== undefined) { + const r = typedefAnchor.range; + const key = `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`; + if (structTypedefRanges.has(key)) continue; + } + + // Enrich function declarations with arity metadata and detect static linkage + const declAnchor = grouped['@declaration.function']; + if (declAnchor !== undefined) { + const fnNode = + findNodeAtRange(tree.rootNode, declAnchor.range, 'function_definition') ?? + findNodeAtRange(tree.rootNode, declAnchor.range, 'declaration'); + if (fnNode !== null) { + const arity = computeCDeclarationArity(fnNode); + if (arity.parameterCount !== undefined) { + grouped['@declaration.parameter-count'] = syntheticCapture( + '@declaration.parameter-count', + fnNode, + String(arity.parameterCount), + ); + } + if (arity.requiredParameterCount !== undefined) { + grouped['@declaration.required-parameter-count'] = syntheticCapture( + '@declaration.required-parameter-count', + fnNode, + String(arity.requiredParameterCount), + ); + } + if (arity.parameterTypes !== undefined) { + grouped['@declaration.parameter-types'] = syntheticCapture( + '@declaration.parameter-types', + fnNode, + 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); + } + } + } + } + + // Enrich call references with arity + const callAnchor = grouped['@reference.call.free'] ?? grouped['@reference.call.member']; + if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) { + const callNode = findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression'); + if (callNode !== null) { + grouped['@reference.arity'] = syntheticCapture( + '@reference.arity', + callNode, + String(computeCCallArity(callNode)), + ); + } + } + + out.push(grouped); + } + + 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: SyntaxNode): 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/header-scan.ts b/gitnexus/src/core/ingestion/languages/c/header-scan.ts new file mode 100644 index 0000000000..034e27f4db --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/header-scan.ts @@ -0,0 +1,58 @@ +import { readdirSync, type Dirent } from 'fs'; +import { join, relative } from 'path'; + +/** C header extensions to scan for in the workspace. */ +const HEADER_EXTENSIONS = new Set(['.h']); + +/** + * Walk `repoPath` recursively and return relative paths of all `.h` files. + * Used by `loadResolutionConfig` so the C resolver can resolve `#include` + * targets that live in `.h` files (classified as C++ by language detection + * but importable from `.c` files). + */ +export function scanHeaderFiles(repoPath: string): ReadonlySet { + const headers = new Set(); + walk(repoPath, repoPath, headers); + return headers; +} + +function walk(dir: string, root: string, out: Set): void { + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' }); + } catch { + return; // permission denied, etc. + } + for (const entry of entries) { + const name = entry.name; + const full = join(dir, name); + if (entry.isDirectory()) { + // Skip common non-source directories and build output dirs. + // Build dirs (dist, build, out, target, _build, .next, cmake-build-*) + // may contain generated headers that shadow source headers. + if ( + name === 'node_modules' || + name === '.git' || + name === 'vendor' || + name === 'dist' || + name === 'build' || + name === 'out' || + name === 'target' || + name === '_build' || + name === '.next' || + name.startsWith('cmake-build') + ) { + continue; + } + walk(full, root, out); + } else if (entry.isFile()) { + const ext = name.slice(name.lastIndexOf('.')); + if (HEADER_EXTENSIONS.has(ext)) { + // Normalize to forward slashes for cross-platform consistency. + // path.relative() returns backslash-separated paths on Windows, + // but the scope-resolution pipeline uses forward slashes uniformly. + out.add(relative(root, full).replace(/\\/g, '/')); + } + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/c/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/c/import-decomposer.ts new file mode 100644 index 0000000000..5cef27430e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/import-decomposer.ts @@ -0,0 +1,55 @@ +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Decompose a `preproc_include` node into a CaptureMatch with structured + * import captures. C #include maps to a wildcard import (all symbols + * from the header are visible). + */ +export function splitCInclude(node: SyntaxNode): CaptureMatch | null { + // node.type === 'preproc_include' + // path field: (string_literal (string_content)) | (system_lib_string) + const pathNode = node.childForFieldName?.('path') ?? null; + if (pathNode === null) { + // Fallback: scan children + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child === null) continue; + if (child.type === 'string_literal' || child.type === 'system_lib_string') { + return buildIncludeCapture(node, child); + } + } + return null; + } + return buildIncludeCapture(node, pathNode); +} + +function buildIncludeCapture(node: SyntaxNode, pathNode: SyntaxNode): CaptureMatch { + let raw: string; + if (pathNode.type === 'string_literal') { + // string_literal has children: `"`, string_content, `"` + // Use namedChildren to find the string_content node + const content = pathNode.namedChildren.find((c) => c.type === 'string_content'); + raw = content?.text ?? pathNode.text.replace(/^"|"$/g, ''); + } else { + // system_lib_string: → strip angle brackets + raw = pathNode.text; + if (raw.startsWith('<') && raw.endsWith('>')) { + raw = raw.slice(1, -1); + } + } + + const isSystem = pathNode.type === 'system_lib_string'; + + const result: Record = { + '@import.statement': nodeToCapture('@import.statement', node), + '@import.kind': syntheticCapture('@import.kind', node, 'wildcard'), + '@import.source': syntheticCapture('@import.source', node, raw), + }; + + if (isSystem) { + result['@import.system'] = syntheticCapture('@import.system', node, 'true'); + } + + return result; +} diff --git a/gitnexus/src/core/ingestion/languages/c/import-target.ts b/gitnexus/src/core/ingestion/languages/c/import-target.ts new file mode 100644 index 0000000000..0cb9c2fb42 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/import-target.ts @@ -0,0 +1,64 @@ +import { dirname, join } from 'path'; + +/** + * Resolve a C #include path to a file in the workspace. + * + * Strategy: + * 1. Check for a same-directory sibling relative to the including file + * (matches C compiler `#include "…"` relative-lookup semantics). + * 2. Check for an exact match (path as-is in the workspace). + * 3. Fall back to suffix matching against all workspace file paths. + * 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, + fromFile: string, + allFilePaths: ReadonlySet, +): string | null { + if (!targetRaw) return null; + + const normalizedTarget = targetRaw.replace(/\\/g, '/'); + + // Same-directory sibling first: mirrors the C compiler's #include "…" + // relative-lookup semantics where the directory of the including + // file is searched before the include-path list. + if (fromFile) { + const siblingRaw = join(dirname(fromFile), targetRaw); + const sibling = siblingRaw.replace(/\\/g, '/'); + if (allFilePaths.has(sibling)) return sibling; + // When targetRaw contains backslashes, the normalized form may + // resolve to a different sibling path — try it as well. + if (targetRaw !== normalizedTarget) { + const siblingAlt = join(dirname(fromFile), normalizedTarget); + const siblingAltNorm = siblingAlt.replace(/\\/g, '/'); + if (allFilePaths.has(siblingAltNorm)) return siblingAltNorm; + } + } + + // Exact match (path as-is in the workspace) + if (allFilePaths.has(normalizedTarget)) return normalizedTarget; + + // Suffix match: find files ending with /targetRaw or equal to targetRaw + 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 || (depth === bestDepth && normalized < bestNormalized)) { + bestDepth = depth; + bestMatch = filePath; + bestNormalized = normalized; + } + } + } + + return bestMatch; +} diff --git a/gitnexus/src/core/ingestion/languages/c/index.ts b/gitnexus/src/core/ingestion/languages/c/index.ts new file mode 100644 index 0000000000..c6900ecba6 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/index.ts @@ -0,0 +1,16 @@ +/** + * C scope-resolution hooks (RFC #909 Ring 3). + */ +export { emitCScopeCaptures } from './captures.js'; +export { interpretCImport, interpretCTypeBinding, normalizeCTypeName } from './interpret.js'; +export { splitCInclude } from './import-decomposer.js'; +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/interpret.ts b/gitnexus/src/core/ingestion/languages/c/interpret.ts new file mode 100644 index 0000000000..e5a15f2fe4 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/interpret.ts @@ -0,0 +1,51 @@ +import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; + +/** + * Interpret a C #include capture into a ParsedImport. + * C includes are always wildcard imports (all symbols from the header). + */ +export function interpretCImport(captures: CaptureMatch): ParsedImport | null { + const source = captures['@import.source']?.text; + if (source === undefined) return null; + + // System headers (e.g. ) are not resolved to local files + if (captures['@import.system'] !== undefined) return null; + + return { kind: 'wildcard', targetRaw: source }; +} + +/** + * Interpret a C type-binding capture into a ParsedTypeBinding. + */ +export function interpretCTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null { + const name = captures['@type-binding.name']?.text; + const type = captures['@type-binding.type']?.text; + if (name === undefined || type === undefined) return null; + + let source: TypeRef['source'] = 'annotation'; + + if (captures['@type-binding.parameter'] !== undefined) { + source = 'parameter-annotation'; + } else if (captures['@type-binding.assignment'] !== undefined) { + source = 'assignment-inferred'; + } + + return { boundName: name, rawTypeName: normalizeCTypeName(type), source }; +} + +/** + * Normalize a C type name: strip pointer/array syntax, qualifiers. + */ +export function normalizeCTypeName(text: string): string { + let t = text.trim(); + // Strip const, volatile, restrict qualifiers + t = t.replace(/\b(const|volatile|restrict|static|extern|inline)\b/g, '').trim(); + // Strip pointer stars + while (t.endsWith('*')) t = t.slice(0, -1).trim(); + while (t.startsWith('*')) t = t.slice(1).trim(); + // Strip array brackets + t = t.replace(/\[.*?\]/g, '').trim(); + // Strip struct/union/enum prefixes + t = t.replace(/^(struct|union|enum)\s+/, ''); + return t; +} diff --git a/gitnexus/src/core/ingestion/languages/c/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/c/merge-bindings.ts new file mode 100644 index 0000000000..b8bd20c6f3 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/merge-bindings.ts @@ -0,0 +1,32 @@ +import type { BindingRef } from 'gitnexus-shared'; + +const TIER: Record = { + local: 0, + namespace: 1, + import: 2, + reexport: 3, + wildcard: 4, +}; + +/** + * C merge bindings: simple first-wins by tier (local > import > wildcard). + * C has no namespaces or reexports, but the tiers are defined for + * compatibility with the shared infrastructure. + */ +export function cMergeBindings( + existing: readonly BindingRef[], + incoming: readonly BindingRef[], + _scopeId: string, +): BindingRef[] { + const seen = new Set(); + return [...existing, ...incoming] + .sort( + (a, b) => + (TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId), + ) + .filter((binding) => { + if (seen.has(binding.def.nodeId)) return false; + seen.add(binding.def.nodeId); + return true; + }); +} diff --git a/gitnexus/src/core/ingestion/languages/c/query.ts b/gitnexus/src/core/ingestion/languages/c/query.ts new file mode 100644 index 0000000000..da49a1a6a6 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/query.ts @@ -0,0 +1,165 @@ +import Parser from 'tree-sitter'; +import C from 'tree-sitter-c'; + +const C_SCOPE_QUERY = ` +;; Scopes +(translation_unit) @scope.module +(struct_specifier) @scope.class +(union_specifier) @scope.class +(function_definition) @scope.function +(compound_statement) @scope.block +(if_statement) @scope.block +(for_statement) @scope.block +(while_statement) @scope.block +(do_statement) @scope.block +(switch_statement) @scope.block +(case_statement) @scope.block + +;; Declarations — struct (named) +(struct_specifier + name: (type_identifier) @declaration.name + body: (field_declaration_list)) @declaration.struct + +;; Declarations — struct (typedef struct { ... } Name) +(type_definition + type: (struct_specifier + body: (field_declaration_list)) + declarator: (type_identifier) @declaration.name) @declaration.struct + +;; Declarations — union (named) +(union_specifier + name: (type_identifier) @declaration.name + body: (field_declaration_list)) @declaration.union + +;; Declarations — union (typedef union { ... } Name) +(type_definition + type: (union_specifier + body: (field_declaration_list)) + declarator: (type_identifier) @declaration.name) @declaration.union + +;; Declarations — enum +(enum_specifier + name: (type_identifier) @declaration.name) @declaration.enum + +;; Declarations — function definition +(function_definition + declarator: (function_declarator + declarator: (identifier) @declaration.name)) @declaration.function + +;; Declarations — function definition with pointer return +(function_definition + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (identifier) @declaration.name))) @declaration.function + +;; Declarations — function declaration (prototype) +;; Note: Both prototypes and definitions are captured as @declaration.function. +;; This may produce duplicate Function nodes in the knowledge graph when a +;; function is declared in a header and defined in a .c file. CALLS edges +;; resolve correctly through scope-based wildcard import chains; the +;; duplication is a graph-quality concern only (no false edges). +(declaration + declarator: (function_declarator + declarator: (identifier) @declaration.name)) @declaration.function + +;; Declarations — function declaration with pointer return (prototype) +(declaration + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (identifier) @declaration.name))) @declaration.function + +;; Declarations — typedef +(type_definition + declarator: (type_identifier) @declaration.name) @declaration.typedef + +;; Declarations — typedef for function pointers: typedef void (*callback)(int, int) +(type_definition + declarator: (function_declarator + declarator: (parenthesized_declarator + (pointer_declarator + declarator: (type_identifier) @declaration.name)))) @declaration.typedef + +;; Declarations — struct fields +(field_declaration + declarator: (field_identifier) @declaration.name) @declaration.field + +;; Declarations — struct fields (pointer) +(field_declaration + declarator: (pointer_declarator + declarator: (field_identifier) @declaration.name)) @declaration.field + +;; Declarations — variables (with initializer) +(declaration + declarator: (init_declarator + declarator: (identifier) @declaration.name)) @declaration.variable + +;; Declarations — macro definitions +(preproc_def + name: (identifier) @declaration.name) @declaration.macro + +(preproc_function_def + name: (identifier) @declaration.name) @declaration.macro + +;; Declarations — enum constants +(enumerator + name: (identifier) @declaration.name) @declaration.const + +;; Imports +(preproc_include) @import.statement + +;; Type bindings — parameter annotations +(parameter_declaration + type: (_) @type-binding.type + declarator: (identifier) @type-binding.name) @type-binding.parameter + +;; Type bindings — variable with type (init_declarator) +(declaration + type: (_) @type-binding.type + declarator: (init_declarator + declarator: (identifier) @type-binding.name)) @type-binding.assignment + +;; References — free calls +;; Note: This also captures calls through function pointer variables (e.g. fp(x)) +;; since tree-sitter-c produces structurally identical AST nodes for both direct +;; function calls and function-pointer-variable calls. A type-based guard to +;; distinguish variable-calls from function-calls is not implemented — this is a +;; known architectural trade-off shared with the Go resolver. The uniqueness +;; constraint in pickUniqueGlobalCallable limits false edge exposure. +(call_expression + function: (identifier) @reference.name) @reference.call.free + +;; References — member calls via pointer (ptr->func()) +(call_expression + function: (field_expression + argument: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.call.member + +;; References — field reads +(field_expression + argument: (_) @reference.receiver + field: (field_identifier) @reference.name) @reference.read + +;; References — field writes (assignment) +(assignment_expression + left: (field_expression + argument: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.write +`; + +let _parser: Parser | null = null; +let _query: Parser.Query | null = null; + +export function getCParser(): Parser { + if (_parser === null) { + _parser = new Parser(); + _parser.setLanguage(C as Parameters[0]); + } + return _parser; +} + +export function getCScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(C as Parameters[0], C_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts new file mode 100644 index 0000000000..90d54e0725 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts @@ -0,0 +1,73 @@ +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; +import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { cProvider } from '../c-cpp.js'; +import { cArityCompatibility, cMergeBindings, resolveCImportTarget } from './index.js'; +import { scanHeaderFiles } from './header-scan.js'; +import { expandCWildcardNames, isStaticName, clearStaticNames } from './static-linkage.js'; + +/** + * C `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by + * the generic `runScopeResolution` orchestrator (RFC #909 Ring 3). + * + * C is a structurally simple language for scope resolution: + * - No classes (structs are value types, no method dispatch) + * - No inheritance (no MRO needed beyond the shared first-wins default) + * - No overloading (arity check is simple: variadic detection only) + * - `#include` is wildcard import (all symbols from header are visible) + * - `static` functions are file-local (not exported) + */ +export const cScopeResolver: ScopeResolver = { + language: SupportedLanguages.C, + languageProvider: cProvider, + importEdgeReason: 'c-scope: include', + + loadResolutionConfig: (repoPath: string) => { + // Clear stale static-linkage data from any previous invocation to + // prevent cross-repo contamination in server-mode scenarios. + clearStaticNames(); + return scanHeaderFiles(repoPath); + }, + + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => { + // Augment allFilePaths with .h files discovered via loadResolutionConfig + // since the phase only passes .c files to the C resolver but #include + // targets .h files classified as C++ in language detection. + const headerPaths = resolutionConfig as ReadonlySet | undefined; + if (headerPaths !== undefined && headerPaths.size > 0) { + const augmented = new Set(allFilePaths); + for (const h of headerPaths) augmented.add(h); + return resolveCImportTarget(targetRaw, fromFile, augmented); + } + 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), + + buildMro: (graph, parsedFiles, nodeLookup) => + buildMro(graph, parsedFiles, nodeLookup, defaultLinearize), + + populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), + + isSuperReceiver: () => false, + + // C is statically typed — disable field fallback heuristic + fieldFallbackOnMethodLookup: false, + // C has no method return types to propagate + propagatesReturnTypesAcrossImports: false, + // C #include brings in all symbols — enable global free call fallback + allowGlobalFreeCallFallback: true, + // C `static` functions have file-local (translation-unit) linkage — + // exclude them from global free-call fallback cross-file resolution. + isFileLocalDef: (def: SymbolDefinition) => { + const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + return isStaticName(def.filePath, simple); + }, +}; diff --git a/gitnexus/src/core/ingestion/languages/c/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/c/simple-hooks.ts new file mode 100644 index 0000000000..537ea07c54 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/simple-hooks.ts @@ -0,0 +1,38 @@ +import type { + CaptureMatch, + ParsedImport, + Scope, + ScopeId, + ScopeTree, + TypeRef, +} from 'gitnexus-shared'; + +/** + * C binding scope: always use default auto-hoist (null). + * C has no self/receiver bindings that need special scoping. + */ +export function cBindingScopeFor( + _decl: CaptureMatch, + _innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + return null; +} + +/** + * C import owning scope: always use default (null). + */ +export function cImportOwningScope( + _imp: ParsedImport, + _innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + return null; +} + +/** + * C receiver binding: always null. C has no methods or receivers. + */ +export function cReceiverBinding(_functionScope: Scope): TypeRef | null { + return null; +} 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 0000000000..5cfd166b19 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/static-linkage.ts @@ -0,0 +1,64 @@ +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. + * + * NOTE: module-level state, single-process-single-repo use only. + * For server-mode or multi-repo-in-one-process use cases, call + * `clearStaticNames()` at the start of each resolution pass to avoid + * stale static-linkage data from a previous invocation. + * + * 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 seen = new Set(); + const names: string[] = []; + for (const def of target.localDefs) { + const name = simpleName(def); + if (name === '') continue; + if (isStaticName(target.filePath, name)) continue; + if (seen.has(name)) continue; + seen.add(name); + names.push(name); + } + return names; +} + +function simpleName(def: SymbolDefinition): string { + return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; +} diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 713b32c078..e063ce20c1 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -71,6 +71,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set boolean; + /** * Optional post-finalize hook to inject cross-file bindings that * aren't modeled via explicit imports. Runs after diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 248ee439f1..44f941daf4 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -36,7 +36,10 @@ export function emitFreeCallFallback( handledSites: Set, model: SemanticModel, workspaceIndex: WorkspaceResolutionIndex, - options: { readonly allowGlobalFallback?: boolean } = {}, + options: { + readonly allowGlobalFallback?: boolean; + readonly isFileLocalDef?: (def: SymbolDefinition) => boolean; + } = {}, ): number { let emitted = 0; const seen = new Set(); @@ -73,7 +76,13 @@ export function emitFreeCallFallback( // the caller does not import the target package. Same-package calls are // caught by findCallableBindingInScope above before reaching here. if (fnDef === undefined && options.allowGlobalFallback === true) { - fnDef = pickUniqueGlobalCallable(site.name, model, scopes); + fnDef = pickUniqueGlobalCallable( + site.name, + model, + scopes, + parsed.filePath, + options.isFileLocalDef, + ); } if (fnDef === undefined) continue; const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup); @@ -107,6 +116,8 @@ function pickUniqueGlobalCallable( name: string, model: SemanticModel, scopes: ScopeResolutionIndexes, + callerFilePath: string, + isFileLocalDef?: (def: SymbolDefinition) => boolean, ): SymbolDefinition | undefined { const scopeDefs: SymbolDefinition[] = []; const scopeSeen = new Set(); @@ -114,6 +125,11 @@ function pickUniqueGlobalCallable( const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName; if (simple !== name) continue; if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue; + // Skip file-local defs (e.g. C `static` functions) that live in a + // different file from the caller — they are logically invisible. + if (isFileLocalDef !== undefined && def.filePath !== callerFilePath && isFileLocalDef(def)) { + continue; + } const key = logicalCallableKey(def); if (scopeSeen.has(key)) continue; scopeSeen.add(key); @@ -125,6 +141,12 @@ function pickUniqueGlobalCallable( const seen = new Set(); const push = (pool: readonly SymbolDefinition[]): void => { for (const def of pool) { + // Apply the same file-local linkage filter as Phase 1 — + // cross-file static defs must never leak through the + // SemanticModel fallback path. + if (isFileLocalDef !== undefined && def.filePath !== callerFilePath && isFileLocalDef(def)) { + continue; + } const key = logicalCallableKey(def); if (seen.has(key)) continue; seen.add(key); diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts index 3d9915fd7f..0667be8f5b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts @@ -15,6 +15,7 @@ import { pythonScopeResolver } from '../../languages/python/scope-resolver.js'; import { csharpScopeResolver } from '../../languages/csharp/scope-resolver.js'; import { typescriptScopeResolver } from '../../languages/typescript/scope-resolver.js'; import { goScopeResolver } from '../../languages/go/scope-resolver.js'; +import { cScopeResolver } from '../../languages/c/scope-resolver.js'; /** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates * this map intersected with `MIGRATED_LANGUAGES` (the per-language @@ -28,4 +29,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap = n [SupportedLanguages.CSharp, csharpScopeResolver], [SupportedLanguages.TypeScript, typescriptScopeResolver], [SupportedLanguages.Go, goScopeResolver], + [SupportedLanguages.C, cScopeResolver], ]); diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 558c9ef303..e2c734a439 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -261,7 +261,10 @@ export function runScopeResolution( handledSites, readonlyModel, workspaceIndex, - { allowGlobalFallback: provider.allowGlobalFreeCallFallback === true }, + { + allowGlobalFallback: provider.allowGlobalFreeCallFallback === true, + isFileLocalDef: provider.isFileLocalDef, + }, ); const { emitted, skipped } = emitReferencesViaLookup( graph, diff --git a/gitnexus/test/fixtures/lang-resolution/c-static-isolation/a.c b/gitnexus/test/fixtures/lang-resolution/c-static-isolation/a.c new file mode 100644 index 0000000000..02a52c42a8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-static-isolation/a.c @@ -0,0 +1,9 @@ +/* a.c — contains a static (file-local) helper function. + * This function must NOT be resolvable from caller.c. */ +static int helper(void) { + return 42; +} + +int public_a(void) { + return helper(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/c-static-isolation/b.c b/gitnexus/test/fixtures/lang-resolution/c-static-isolation/b.c new file mode 100644 index 0000000000..231a8e434f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-static-isolation/b.c @@ -0,0 +1,11 @@ +/* b.c — contains a non-static (externally visible) helper function. + * This function SHOULD be resolvable from caller.c. */ +#include "b.h" + +int helper(void) { + return 99; +} + +int public_b(void) { + return helper(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/c-static-isolation/b.h b/gitnexus/test/fixtures/lang-resolution/c-static-isolation/b.h new file mode 100644 index 0000000000..bd9b6dd2f7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-static-isolation/b.h @@ -0,0 +1,8 @@ +/* b.h — public header for b.c */ +#ifndef B_H +#define B_H + +int helper(void); +int public_b(void); + +#endif diff --git a/gitnexus/test/fixtures/lang-resolution/c-static-isolation/caller.c b/gitnexus/test/fixtures/lang-resolution/c-static-isolation/caller.c new file mode 100644 index 0000000000..802e134d90 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-static-isolation/caller.c @@ -0,0 +1,9 @@ +/* caller.c — includes only b.h, calls helper(). + * Should resolve to b.c:helper, NOT a.c:static helper. */ +#include "b.h" + +int main(void) { + int x = helper(); + int y = public_b(); + return x + y; +} diff --git a/gitnexus/test/fixtures/lang-resolution/c-structs/main.c b/gitnexus/test/fixtures/lang-resolution/c-structs/main.c new file mode 100644 index 0000000000..334f36b162 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-structs/main.c @@ -0,0 +1,9 @@ +#include "service.h" + +int main(void) { + struct Service *svc = create_service(); + service_add_user(svc, "Alice", 25); + service_add_user(svc, "Bob", 32); + destroy_service(svc); + return 0; +} diff --git a/gitnexus/test/fixtures/lang-resolution/c-structs/service.c b/gitnexus/test/fixtures/lang-resolution/c-structs/service.c new file mode 100644 index 0000000000..85c4cdef6b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-structs/service.c @@ -0,0 +1,20 @@ +#include "service.h" +#include + +struct Service *create_service(void) { + struct Service *svc = malloc(sizeof(struct Service)); + svc->admin = create_user("admin", 30); + svc->user_count = 0; + return svc; +} + +void service_add_user(struct Service *svc, const char *name, int age) { + struct User *user = create_user(name, age); + svc->user_count++; + free_user(user); +} + +void destroy_service(struct Service *svc) { + free_user(svc->admin); + free(svc); +} diff --git a/gitnexus/test/fixtures/lang-resolution/c-structs/service.h b/gitnexus/test/fixtures/lang-resolution/c-structs/service.h new file mode 100644 index 0000000000..a5056edc75 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-structs/service.h @@ -0,0 +1,15 @@ +#ifndef SERVICE_H +#define SERVICE_H + +#include "user.h" + +struct Service { + struct User *admin; + int user_count; +}; + +struct Service *create_service(void); +void service_add_user(struct Service *svc, const char *name, int age); +void destroy_service(struct Service *svc); + +#endif diff --git a/gitnexus/test/fixtures/lang-resolution/c-structs/user.c b/gitnexus/test/fixtures/lang-resolution/c-structs/user.c new file mode 100644 index 0000000000..2f3654913b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-structs/user.c @@ -0,0 +1,18 @@ +#include "user.h" +#include +#include + +struct User *create_user(const char *name, int age) { + struct User *user = malloc(sizeof(struct User)); + strncpy(user->name, name, sizeof(user->name) - 1); + user->age = age; + return user; +} + +void free_user(struct User *user) { + free(user); +} + +int get_user_age(const struct User *user) { + return user->age; +} diff --git a/gitnexus/test/fixtures/lang-resolution/c-structs/user.h b/gitnexus/test/fixtures/lang-resolution/c-structs/user.h new file mode 100644 index 0000000000..6e14c2425b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-structs/user.h @@ -0,0 +1,13 @@ +#ifndef USER_H +#define USER_H + +struct User { + char name[64]; + int age; +}; + +struct User *create_user(const char *name, int age); +void free_user(struct User *user); +int get_user_age(const struct User *user); + +#endif diff --git a/gitnexus/test/integration/resolvers/c.test.ts b/gitnexus/test/integration/resolvers/c.test.ts new file mode 100644 index 0000000000..980764b8c0 --- /dev/null +++ b/gitnexus/test/integration/resolvers/c.test.ts @@ -0,0 +1,111 @@ +/** + * C: struct + include-based imports + function calls across files + */ +import { describe, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, + createResolverParityIt, + getRelationships, + getNodesByLabel, + edgeSet, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +const it = createResolverParityIt('c'); + +// --------------------------------------------------------------------------- +// C structs + include-based imports + cross-file function calls +// --------------------------------------------------------------------------- + +describe('C struct & include resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'c-structs'), () => {}); + }, 60000); + + it('detects User and Service structs', () => { + const structs = getNodesByLabel(result, 'Struct'); + expect(structs).toContain('User'); + expect(structs).toContain('Service'); + }); + + it('detects functions across all files', () => { + const fns = getNodesByLabel(result, 'Function'); + expect(fns).toContain('main'); + expect(fns).toContain('create_user'); + expect(fns).toContain('free_user'); + expect(fns).toContain('get_user_age'); + expect(fns).toContain('create_service'); + expect(fns).toContain('service_add_user'); + expect(fns).toContain('destroy_service'); + }); + + it('resolves #include imports between .c and .h files', () => { + const imports = getRelationships(result, 'IMPORTS'); + const edges = edgeSet(imports); + // user.c includes user.h + expect(edges).toContain('user.c → user.h'); + // service.h includes user.h + expect(edges).toContain('service.h → user.h'); + // service.c includes service.h + expect(edges).toContain('service.c → service.h'); + // main.c includes service.h + expect(edges).toContain('main.c → service.h'); + }); + + it('emits CALLS edges for cross-file function calls', () => { + const calls = getRelationships(result, 'CALLS'); + const edges = edgeSet(calls); + // main.c calls functions from service + expect(edges).toContain('main → create_service'); + expect(edges).toContain('main → service_add_user'); + expect(edges).toContain('main → destroy_service'); + // service.c calls functions from user + expect(edges).toContain('service_add_user → create_user'); + expect(edges).toContain('service_add_user → free_user'); + expect(edges).toContain('destroy_service → free_user'); + }); +}); + +// --------------------------------------------------------------------------- +// C static function isolation — static functions must NOT leak across files +// --------------------------------------------------------------------------- + +describe('C static function isolation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'c-static-isolation'), () => {}); + }, 60000); + + it('detects both static and non-static helper functions', () => { + const fns = getNodesByLabel(result, 'Function'); + expect(fns).toContain('helper'); + expect(fns).toContain('public_a'); + expect(fns).toContain('public_b'); + expect(fns).toContain('main'); + }); + + it('caller.c calls b:helper via include, NOT a:static helper', () => { + const calls = getRelationships(result, 'CALLS'); + const edges = edgeSet(calls); + + // caller.c should call public_b (included via b.h) + expect(edges).toContain('main → public_b'); + + // a.c's static helper calls itself locally + expect(edges).toContain('public_a → helper'); + + // caller.c should NOT have a CALLS edge to a.c's static helper. + // Filter edges to only those originating from main → helper to + // verify the correct target file. + const mainToHelper = calls.filter((r) => r.source === 'main' && r.target === 'helper'); + // If a main→helper edge exists, it should point to b.c, not a.c + for (const edge of mainToHelper) { + expect(edge.targetFilePath).not.toContain('a.c'); + } + }); +}); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index e92526b192..b8e2676c6a 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -9,6 +9,18 @@ import type { PipelineResult } from '../../../src/types/pipeline.js'; import type { GraphRelationship } from 'gitnexus-shared'; const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly>> = { + c: new Set([ + // The legacy DAG path does not resolve the main → create_service call + // because the function prototype in the .h file and the definition in + // the .c file create a dedup ambiguity. The registry-primary path + // resolves it via scope-based wildcard import binding. + 'emits CALLS edges for cross-file function calls', + // The legacy DAG path does not resolve cross-file calls through + // #include → prototype chains. The scope-based path resolves + // caller.c → b.h → public_b via wildcard import binding + + // isFileLocalDef filtering of static functions. + 'caller.c calls b:helper via include, NOT a:static helper', + ]), csharp: new Set([ 'emits the using-import edge App/Program.cs -> Models/User.cs through the scope-resolution path', // Generic type-argument USES edges are emitted by the registry-primary diff --git a/gitnexus/test/unit/registry-primary-flag.test.ts b/gitnexus/test/unit/registry-primary-flag.test.ts index 5f688c7327..9e8be3455a 100644 --- a/gitnexus/test/unit/registry-primary-flag.test.ts +++ b/gitnexus/test/unit/registry-primary-flag.test.ts @@ -153,6 +153,7 @@ describe('primaryLanguages', () => { process.env['REGISTRY_PRIMARY_CSHARP'] = 'false'; process.env['REGISTRY_PRIMARY_TYPESCRIPT'] = 'false'; process.env['REGISTRY_PRIMARY_GO'] = 'false'; + process.env['REGISTRY_PRIMARY_C'] = 'false'; process.env['REGISTRY_PRIMARY_JAVA'] = '1'; const enabled = primaryLanguages(); expect(enabled.has(SupportedLanguages.Python)).toBe(false); 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 0000000000..fddf1b9323 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/c/c-arity.test.ts @@ -0,0 +1,212 @@ +/** + * 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); + }); + + it('returns unknown arity for K&R empty parameter list int foo()', () => { + const node = parseFunctionNode('int foo() { return 0; }'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + // K&R old-style: unspecified parameters, NOT zero parameters + expect(arity.parameterCount).toBeUndefined(); + expect(arity.requiredParameterCount).toBeUndefined(); + expect(arity.parameterTypes).toBeUndefined(); + }); + + it('distinguishes K&R int foo() from explicit int foo(void)', () => { + const knrNode = parseFunctionNode('int foo() { return 0; }'); + const voidNode = parseFunctionNode('int foo(void) { return 0; }'); + expect(knrNode).not.toBeNull(); + expect(voidNode).not.toBeNull(); + + const knrArity = computeCDeclarationArity(knrNode!); + const voidArity = computeCDeclarationArity(voidNode!); + + // K&R: unknown arity + expect(knrArity.parameterCount).toBeUndefined(); + // Explicit void: zero params + expect(voidArity.parameterCount).toBe(0); + expect(voidArity.requiredParameterCount).toBe(0); + }); + + it('returns unknown arity for K&R prototype int foo();', () => { + const node = parseFunctionNode('int foo();'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + expect(arity.parameterCount).toBeUndefined(); + expect(arity.requiredParameterCount).toBeUndefined(); + }); +}); + +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 0000000000..68c9ef22b1 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/c/c-captures.test.ts @@ -0,0 +1,355 @@ +/** + * 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 ', (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-header-scan.test.ts b/gitnexus/test/unit/scope-resolution/c/c-header-scan.test.ts new file mode 100644 index 0000000000..1543db0067 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/c/c-header-scan.test.ts @@ -0,0 +1,103 @@ +/** + * Unit tests for C header scanning — specifically the skip-list + * for build output directories. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { scanHeaderFiles } from '../../../../src/core/ingestion/languages/c/header-scan.js'; + +const TMP = join(__dirname, '__header_scan_tmp__'); + +function touch(rel: string): void { + const full = join(TMP, rel); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, ''); +} + +beforeEach(() => { + mkdirSync(TMP, { recursive: true }); +}); + +afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); +}); + +describe('scanHeaderFiles — build-directory skip list', () => { + it('finds .h files in source directories', () => { + touch('src/foo.h'); + touch('include/bar.h'); + const headers = scanHeaderFiles(TMP); + expect(headers).toContain('src/foo.h'); + expect(headers).toContain('include/bar.h'); + }); + + it('skips node_modules', () => { + touch('node_modules/dep/header.h'); + touch('src/real.h'); + const headers = scanHeaderFiles(TMP); + expect(headers).not.toContain('node_modules/dep/header.h'); + expect(headers).toContain('src/real.h'); + }); + + it('skips .git directory', () => { + touch('.git/refs/header.h'); + const headers = scanHeaderFiles(TMP); + expect(headers.size).toBe(0); + }); + + it('skips vendor directory', () => { + touch('vendor/lib/header.h'); + const headers = scanHeaderFiles(TMP); + expect(headers.size).toBe(0); + }); + + it('skips dist directory', () => { + touch('dist/generated.h'); + touch('src/real.h'); + const headers = scanHeaderFiles(TMP); + expect(headers).not.toContain('dist/generated.h'); + expect(headers).toContain('src/real.h'); + }); + + it('skips build directory', () => { + touch('build/config.h'); + const headers = scanHeaderFiles(TMP); + expect(headers).not.toContain('build/config.h'); + }); + + it('skips out directory', () => { + touch('out/gen/auto.h'); + const headers = scanHeaderFiles(TMP); + expect(headers.size).toBe(0); + }); + + it('skips target directory', () => { + touch('target/release/bindings.h'); + const headers = scanHeaderFiles(TMP); + expect(headers.size).toBe(0); + }); + + it('skips _build directory', () => { + touch('_build/default/lib.h'); + const headers = scanHeaderFiles(TMP); + expect(headers.size).toBe(0); + }); + + it('skips .next directory', () => { + touch('.next/cache/header.h'); + const headers = scanHeaderFiles(TMP); + expect(headers.size).toBe(0); + }); + + it('skips cmake-build-* directories', () => { + touch('cmake-build-debug/generated.h'); + touch('cmake-build-release/generated.h'); + touch('src/real.h'); + const headers = scanHeaderFiles(TMP); + expect(headers).not.toContain('cmake-build-debug/generated.h'); + expect(headers).not.toContain('cmake-build-release/generated.h'); + expect(headers).toContain('src/real.h'); + }); +}); 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 0000000000..549e651bb8 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/c/c-imports.test.ts @@ -0,0 +1,166 @@ +/** + * 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'); + }); + + it('prefers same-directory sibling over deeper suffix match', () => { + // src/foo.c includes "bar.h" — src/bar.h should win over include/bar.h + const result = resolveCImportTarget( + 'bar.h', + 'src/foo.c', + new Set(['include/bar.h', 'src/bar.h']), + ); + expect(result).toBe('src/bar.h'); + }); + + it('prefers same-directory sibling over shallower suffix match', () => { + // deep/nested/main.c includes "foo.h" — deep/nested/foo.h wins over foo.h + const result = resolveCImportTarget( + 'foo.h', + 'deep/nested/main.c', + new Set(['foo.h', 'deep/nested/foo.h']), + ); + expect(result).toBe('deep/nested/foo.h'); + }); + + it('falls back to suffix match when no same-directory sibling exists', () => { + const result = resolveCImportTarget('missing.h', 'src/foo.c', new Set(['lib/missing.h'])); + expect(result).toBe('lib/missing.h'); + }); + + it('same-directory sibling with nested target path', () => { + // src/foo.c includes "sub/bar.h" — src/sub/bar.h should win + const result = resolveCImportTarget( + 'sub/bar.h', + 'src/foo.c', + new Set(['other/sub/bar.h', 'src/sub/bar.h']), + ); + expect(result).toBe('src/sub/bar.h'); + }); +});