Merge branch 'main' into fix/1492-safe-pretool-augment

This commit is contained in:
Gergő Magyar 2026-05-11 10:36:36 +01:00 committed by GitHub
commit 911dbad949
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2163 additions and 42 deletions

View file

@ -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<string> = 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({

View file

@ -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;
}

View file

@ -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';
}

View file

@ -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<ReturnType<typeof getCParser>['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<string>();
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
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;
}

View file

@ -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<string> {
const headers = new Set<string>();
walk(repoPath, repoPath, headers);
return headers;
}
function walk(dir: string, root: string, out: Set<string>): 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, '/'));
}
}
}
}

View file

@ -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: <stdio.h> → 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<string, Capture> = {
'@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;
}

View file

@ -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>,
): 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;
}

View file

@ -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';

View file

@ -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. <stdio.h>) 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;
}

View file

@ -0,0 +1,32 @@
import type { BindingRef } from 'gitnexus-shared';
const TIER: Record<BindingRef['origin'], number> = {
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<string>();
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;
});
}

View file

@ -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<Parser['setLanguage']>[0]);
}
return _parser;
}
export function getCScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(C as Parameters<Parser['setLanguage']>[0], C_SCOPE_QUERY);
}
return _query;
}

View file

@ -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<string> | 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);
},
};

View file

@ -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;
}

View file

@ -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<string, Set<string>>();
/** 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<string>();
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<string>();
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 ?? '';
}

View file

@ -71,6 +71,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
SupportedLanguages.CSharp,
SupportedLanguages.TypeScript,
SupportedLanguages.Go,
SupportedLanguages.C,
]);
/**

View file

@ -472,6 +472,18 @@ export interface ScopeResolver {
*/
readonly allowGlobalFreeCallFallback?: boolean;
/**
* Optional predicate to identify definitions with file-local linkage
* (e.g. C `static` functions). When provided, `pickUniqueGlobalCallable`
* excludes defs where `isFileLocalDef(def) === true` and the def lives
* in a different file from the caller. This prevents the global free-call
* fallback from creating CALLS edges to file-local symbols that are
* logically invisible from the caller's translation unit.
*
* Languages without file-local linkage semantics leave this undefined.
*/
readonly isFileLocalDef?: (def: SymbolDefinition) => boolean;
/**
* Optional post-finalize hook to inject cross-file bindings that
* aren't modeled via explicit imports. Runs after

View file

@ -36,7 +36,10 @@ export function emitFreeCallFallback(
handledSites: Set<string>,
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<string>();
@ -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<string>();
@ -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<string>();
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);

View file

@ -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<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.CSharp, csharpScopeResolver],
[SupportedLanguages.TypeScript, typescriptScopeResolver],
[SupportedLanguages.Go, goScopeResolver],
[SupportedLanguages.C, cScopeResolver],
]);

View file

@ -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,

View file

@ -11,6 +11,7 @@ import os from 'os';
import fs from 'fs/promises';
import { isIP } from 'net';
import { logger } from '../core/logger.js';
import { parseRepoNameFromUrl } from '../storage/git.js';
/** Root directory for all cloned repositories. Targets must resolve inside this. */
const CLONE_ROOT = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos'));
@ -29,20 +30,17 @@ const REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
* clone root via path traversal.
*/
export function extractRepoName(url: string): string {
// Strip trailing slashes without a regex to avoid polynomial-ReDoS on
// pathological inputs like `https://x.com/y` + '/'.repeat(1e6). CodeQL's
// js/polynomial-redos flagged `/\/+$/` here.
let end = url.length;
while (end > 0 && url.charCodeAt(end - 1) === 47 /* '/' */) end--;
const cleaned = url.slice(0, end);
const lastSegment = cleaned.split(/[/:]/).pop() || '';
const stripped = lastSegment.endsWith('.git') ? lastSegment.slice(0, -4) : lastSegment;
if (!stripped || stripped === '.' || stripped === '..' || !REPO_NAME_PATTERN.test(stripped)) {
const name = parseRepoNameFromUrl(url);
if (
!name ||
name === '.' ||
name === '..' ||
name === 'unknown' ||
!REPO_NAME_PATTERN.test(name)
) {
throw new Error('Could not extract a valid repository name from URL');
}
return stripped;
return name;
}
/** Get the clone target directory for a repo name. */
@ -399,8 +397,8 @@ export async function cloneOrPull(
}
// Always validate the requested URL — the prior shape only ran this in
// the clone branch, leaving the pull branch as an SSRF / blocked-host
// bypass when an existing clone shared the basename of an attacker URL.
// the code path where the repo was cloned. Now it runs unconditionally,
// preventing SSRF / blocked-host bypasses even when targetDir already exists.
validateGitUrl(url);
const exists = await fs.access(path.join(safeTarget, '.git')).then(

View file

@ -255,24 +255,63 @@ export const getRemoteOriginUrl = (repoPath: string): string | null => {
};
/**
* Parse a repository name out of a git remote URL. Handles the common
* SSH (`git@host:owner/repo.git`), HTTPS (`https://host/owner/repo.git`),
* `git://`, `ssh://`, and `file://` shapes. Returns `null` for empty /
* unparseable input.
* Sanitize a repository name to prevent argument injection and ensure
* cross-platform filesystem compatibility.
*
* The heuristic: strip a trailing `.git` and trailing slashes, then
* take the segment after the last `/` or `:`.
* 1. Strips leading dashes to prevent git command-line argument injection
* (e.g., --upload-pack=evil).
* 2. Replaces characters that are unsafe for directory names across
* platforms (Windows/macOS/Linux) with underscores.
* 3. Blocks path traversal segments ("." and "..") and Windows reserved
* names (e.g., CON, NUL) to prevent directory escape.
*/
export const sanitizeRepoName = (name: string): string => {
// 1. Prevent argument injection by stripping leading dashes.
// 2. Remove characters that are not alphanumerics, dots, underscores, or dashes.
const sanitized = name.replace(/^-+/, '').replace(/[^a-zA-Z0-9._-]/g, '_');
// 3. Block path traversal segments and Windows reserved names.
// Windows reserved names like CON, PRN, AUX, NUL, COM1-9, LPT1-9 cannot
// be used as directory names on Windows even if they have an extension.
const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i;
if (!sanitized || sanitized === '.' || sanitized === '..' || reserved.test(sanitized)) {
return 'unknown';
}
return sanitized;
};
/**
* Parse a repository name out of a git remote URL. Handles common shapes
* including SSH (git@host:owner/repo.git) and HTTPS (https://host/owner/repo.git).
*
* Returns a sanitized, filesystem-safe name or null if no name could be inferred.
* Returning null (rather than 'unknown') allows callers to use ?? null-coalescing
* for fallbacks without risk of registry collisions on 'unknown'.
*/
export const parseRepoNameFromUrl = (url: string | null | undefined): string | null => {
if (!url) return null;
const trimmed = url.trim();
if (!trimmed) return null;
// Strip `.git` suffix (case-insensitive) and any trailing slashes.
const withoutSuffix = trimmed.replace(/\.git\/*$/i, '').replace(/\/+$/, '');
// Last path segment, splitting on either `/` or `:` (covers SSH form).
const m = withoutSuffix.match(/[/:]([^/:]+)$/);
const candidate = m ? m[1] : withoutSuffix;
return candidate || null;
// Strip trailing slashes without a regex to avoid polynomial-ReDoS on
// pathological inputs like `https://x.com/y` + '/'.repeat(1e6).
let end = trimmed.length;
while (end > 0 && trimmed.charCodeAt(end - 1) === 47 /* '/' */) end--;
let cleaned = trimmed.slice(0, end);
// Strip trailing .git (case-insensitive)
if (cleaned.toLowerCase().endsWith('.git')) {
cleaned = cleaned.slice(0, -4);
}
// Last path segment, handling colons for SSH URLs and path traversal.
// Split on both / and : to consistently extract the last part.
const candidate = cleaned.split(/[/:]/).pop() || '';
if (!candidate) return null;
const safe = sanitizeRepoName(candidate);
return safe === 'unknown' ? null : safe;
};
/**

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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

View file

@ -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;
}

View file

@ -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;
}

View file

@ -0,0 +1,20 @@
#include "service.h"
#include <stdlib.h>
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);
}

View file

@ -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

View file

@ -0,0 +1,18 @@
#include "user.h"
#include <stdlib.h>
#include <string.h>
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;
}

View file

@ -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

View file

@ -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');
}
});
});

View file

@ -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<Record<string, ReadonlySet<string>>> = {
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

View file

@ -7,12 +7,12 @@ import {
buildCloneArgs,
normalizeGitUrlForCompare,
assertRemoteMatchesRequestedUrl,
getRemoteOriginUrl,
} from '../../src/server/git-clone.js';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { getRemoteOriginUrl } from '../../src/storage/git.js';
describe('git-clone', () => {
describe('extractRepoName', () => {
@ -50,17 +50,6 @@ describe('git-clone', () => {
expect(() => extractRepoName('https://example.com/foo:.')).toThrow('valid repository name');
});
it('rejects URLs with shell metacharacters in the last segment', () => {
// The split on /[/:]/ does not split on backslashes or other shell chars,
// so a name like `repo;rm -rf /` would slip through without the pattern.
expect(() => extractRepoName('https://example.com/foo:repo;rm')).toThrow(
'valid repository name',
);
expect(() => extractRepoName('https://example.com/foo:repo$x')).toThrow(
'valid repository name',
);
});
it('rejects empty input', () => {
expect(() => extractRepoName('')).toThrow('valid repository name');
});
@ -77,6 +66,31 @@ describe('git-clone', () => {
// multiple seconds on 10k slashes).
expect(elapsedMs).toBeLessThan(500);
});
it('strips leading dashes to prevent argument injection', () => {
expect(extractRepoName('https://github.com/user/--upload-pack=payload.git')).toBe(
'upload-pack_payload',
);
expect(extractRepoName('https://github.com/user/-repo')).toBe('repo');
});
it('sanitizes unsafe directory characters', () => {
// sanitizeRepoName turns <tag> into _tag_
expect(extractRepoName('https://github.com/user/repo<tag>.git')).toBe('repo_tag_');
});
it('sanitizes shell metacharacters in URL segments', () => {
// The split on /[/:]/ does not split on backslashes or other shell chars,
// so a name like `repo;rm -rf /` would slip through without the pattern.
// After fix/sanitize-repo-name, these are sanitized to underscores.
expect(extractRepoName('https://example.com/foo:repo;rm')).toBe('repo_rm');
expect(extractRepoName('https://example.com/foo:repo$x')).toBe('repo_x');
});
it('sanitizes whitespace and backslashes', () => {
expect(extractRepoName('https://example.com/foo:repo name')).toBe('repo_name');
expect(extractRepoName('https://example.com/foo:repo\\name')).toBe('repo_name');
});
});
describe('getCloneDir', () => {
@ -209,7 +223,7 @@ describe('git-clone', () => {
it('blocks NAT64 with embedded RFC1918 addresses', () => {
// The startsWith('64:ff9b:') check covers any embedded IPv4. These
// explicit RFC1918 cases document SSRF coverage for the full private
// explicit RFC1918 architectures document SSRF coverage for the full private
// IPv4 surface — not just loopback and cloud metadata.
expect(() => validateGitUrl('http://[64:ff9b::a00:1]/repo.git')).toThrow('private/internal'); // 10.0.0.1
expect(() => validateGitUrl('http://[64:ff9b::ac10:1]/repo.git')).toThrow('private/internal'); // 172.16.0.1

View file

@ -8,6 +8,8 @@ import {
getCurrentCommit,
getGitRoot,
findGitRootByDotGit,
parseRepoNameFromUrl,
sanitizeRepoName,
} from '../../src/storage/git.js';
// Mock child_process.execSync
@ -164,4 +166,61 @@ describe('git utilities', () => {
}
});
});
describe('sanitizeRepoName', () => {
it('strips leading dashes', () => {
expect(sanitizeRepoName('--repo')).toBe('repo');
});
it('replaces unsafe characters with underscores', () => {
expect(sanitizeRepoName('repo<tag>')).toBe('repo_tag_');
expect(sanitizeRepoName('repo:name')).toBe('repo_name');
expect(sanitizeRepoName('repo"quoted"')).toBe('repo_quoted_');
});
it('blocks path traversal segments', () => {
expect(sanitizeRepoName('.')).toBe('unknown');
expect(sanitizeRepoName('..')).toBe('unknown');
});
it('blocks Windows reserved names', () => {
expect(sanitizeRepoName('CON')).toBe('unknown');
expect(sanitizeRepoName('prn')).toBe('unknown');
expect(sanitizeRepoName('AUX')).toBe('unknown');
expect(sanitizeRepoName('NUL')).toBe('unknown');
expect(sanitizeRepoName('COM1')).toBe('unknown');
expect(sanitizeRepoName('LPT9')).toBe('unknown');
// Reserved names with extensions
expect(sanitizeRepoName('CON.txt')).toBe('unknown');
expect(sanitizeRepoName('NUL.tar.gz')).toBe('unknown');
expect(sanitizeRepoName('AUX.local')).toBe('unknown');
});
it('returns unknown for empty or invalid input', () => {
expect(sanitizeRepoName('')).toBe('unknown');
expect(sanitizeRepoName('---')).toBe('unknown');
});
});
describe('parseRepoNameFromUrl', () => {
it('extracts and sanitizes name from HTTPS URL', () => {
expect(parseRepoNameFromUrl('https://github.com/user/my-repo.git')).toBe('my-repo');
expect(parseRepoNameFromUrl('https://github.com/user/--payload.git')).toBe('payload');
});
it('extracts and sanitizes name from SSH URL', () => {
expect(parseRepoNameFromUrl('git@github.com:user/my-repo.git')).toBe('my-repo');
expect(parseRepoNameFromUrl('git@github.com:--payload.git')).toBe('payload');
});
it('returns null for all-dash inputs (prevents registry collision)', () => {
expect(parseRepoNameFromUrl('https://github.com/user/---.git')).toBeNull();
});
it('returns null for empty URL', () => {
expect(parseRepoNameFromUrl('')).toBeNull();
expect(parseRepoNameFromUrl(null)).toBeNull();
});
});
});

View file

@ -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);

View file

@ -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>): 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');
});
});

View file

@ -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 <stdio.h>', (t) => t.includes('@import.statement'));
expect(m).toBeDefined();
expect(m!['@import.system']).toBeDefined();
});
it('captures nested path includes', () => {
const m = findMatch('#include "utils/helpers.h"', (t) => t.includes('@import.statement'));
expect(m).toBeDefined();
expect(m!['@import.source'].text).toBe('utils/helpers.h');
});
});
describe('emitCScopeCaptures — references', () => {
it('captures free call invocations', () => {
const m = findMatch('void f(void) { foo(); }', (t) => t.includes('@reference.call.free'));
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('foo');
});
it('captures member call via pointer (ptr->func())', () => {
const m = findMatch('void f(struct S *s) { s->method(); }', (t) =>
t.includes('@reference.call.member'),
);
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('method');
});
it('captures field reads', () => {
const m = findMatch('void f(struct S *s) { int x = s->field; }', (t) =>
t.includes('@reference.read'),
);
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('field');
});
it('captures field writes (assignment)', () => {
const m = findMatch('void f(struct S *s) { s->field = 1; }', (t) =>
t.includes('@reference.write'),
);
expect(m).toBeDefined();
expect(m!['@reference.name'].text).toBe('field');
});
});
describe('emitCScopeCaptures — type bindings', () => {
it('captures parameter type annotations', () => {
const m = findMatch('void f(int x) { }', (t) => t.includes('@type-binding.parameter'));
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('x');
});
it('captures variable type bindings', () => {
const m = findMatch('void f(void) { int x = 1; }', (t) =>
t.includes('@type-binding.assignment'),
);
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('x');
});
});
describe('emitCScopeCaptures — arity metadata', () => {
it('synthesizes parameter-count on function definitions', () => {
const m = findMatch('int add(int a, int b) { return a + b; }', (t) =>
t.includes('@declaration.parameter-count'),
);
expect(m).toBeDefined();
expect(m!['@declaration.parameter-count'].text).toBe('2');
});
it('synthesizes parameter-types on function definitions', () => {
const m = findMatch('int add(int a, float b) { return 0; }', (t) =>
t.includes('@declaration.parameter-types'),
);
expect(m).toBeDefined();
const types = JSON.parse(m!['@declaration.parameter-types'].text);
expect(types).toEqual(['int', 'float']);
});
it('(void) parameter list yields zero parameters', () => {
const m = findMatch('void f(void) { }', (t) => t.includes('@declaration.function'));
expect(m).toBeDefined();
expect(m!['@declaration.parameter-count'].text).toBe('0');
expect(m!['@declaration.required-parameter-count'].text).toBe('0');
});
it('variadic function has undefined parameter-count but defined required-parameter-count', () => {
const m = findMatch('int printf(const char *fmt, ...) { return 0; }', (t) =>
t.includes('@declaration.function'),
);
expect(m).toBeDefined();
// variadic → parameterCount is undefined (not emitted)
expect(m!['@declaration.parameter-count']).toBeUndefined();
expect(m!['@declaration.required-parameter-count'].text).toBe('1');
});
it('synthesizes arity on call references', () => {
const m = findMatch(
'void f(void) { add(1, 2); }',
(t) => t.includes('@reference.call.free') && t.includes('@reference.arity'),
);
expect(m).toBeDefined();
expect(m!['@reference.arity'].text).toBe('2');
});
it('zero-argument call has arity 0', () => {
const m = findMatch(
'void f(void) { init(); }',
(t) => t.includes('@reference.call.free') && t.includes('@reference.arity'),
);
expect(m).toBeDefined();
expect(m!['@reference.arity'].text).toBe('0');
});
});
describe('emitCScopeCaptures — static storage class', () => {
beforeEach(() => {
clearStaticNames();
});
it('marks static function definitions as file-local', () => {
emitCScopeCaptures('static int helper(void) { return 0; }', 'a.c');
expect(isStaticName('a.c', 'helper')).toBe(true);
});
it('does not mark non-static function definitions as file-local', () => {
emitCScopeCaptures('int helper(void) { return 0; }', 'a.c');
expect(isStaticName('a.c', 'helper')).toBe(false);
});
it('marks static function prototypes as file-local', () => {
emitCScopeCaptures('static int helper(int x);', 'a.c');
expect(isStaticName('a.c', 'helper')).toBe(true);
});
it('static functions are scoped to their file', () => {
emitCScopeCaptures('static int helper(void) { return 0; }', 'a.c');
emitCScopeCaptures('int helper(void) { return 1; }', 'b.c');
expect(isStaticName('a.c', 'helper')).toBe(true);
expect(isStaticName('b.c', 'helper')).toBe(false);
});
it('static pointer-return functions are detected', () => {
emitCScopeCaptures('static char *get_buffer(void) { return 0; }', 'a.c');
expect(isStaticName('a.c', 'get_buffer')).toBe(true);
});
});

View file

@ -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');
});
});

View file

@ -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 <stdio.h>"', () => {
const node = parseIncludeNode('#include <stdio.h>');
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');
});
});