mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-24 00:51:53 +00:00
Merge branch 'main' into fix/1492-safe-pretool-augment
This commit is contained in:
commit
911dbad949
39 changed files with 2163 additions and 42 deletions
|
|
@ -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({
|
||||
|
|
|
|||
102
gitnexus/src/core/ingestion/languages/c/arity-metadata.ts
Normal file
102
gitnexus/src/core/ingestion/languages/c/arity-metadata.ts
Normal 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;
|
||||
}
|
||||
20
gitnexus/src/core/ingestion/languages/c/arity.ts
Normal file
20
gitnexus/src/core/ingestion/languages/c/arity.ts
Normal 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';
|
||||
}
|
||||
142
gitnexus/src/core/ingestion/languages/c/captures.ts
Normal file
142
gitnexus/src/core/ingestion/languages/c/captures.ts
Normal 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;
|
||||
}
|
||||
58
gitnexus/src/core/ingestion/languages/c/header-scan.ts
Normal file
58
gitnexus/src/core/ingestion/languages/c/header-scan.ts
Normal 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, '/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
55
gitnexus/src/core/ingestion/languages/c/import-decomposer.ts
Normal file
55
gitnexus/src/core/ingestion/languages/c/import-decomposer.ts
Normal 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;
|
||||
}
|
||||
64
gitnexus/src/core/ingestion/languages/c/import-target.ts
Normal file
64
gitnexus/src/core/ingestion/languages/c/import-target.ts
Normal 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;
|
||||
}
|
||||
16
gitnexus/src/core/ingestion/languages/c/index.ts
Normal file
16
gitnexus/src/core/ingestion/languages/c/index.ts
Normal 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';
|
||||
51
gitnexus/src/core/ingestion/languages/c/interpret.ts
Normal file
51
gitnexus/src/core/ingestion/languages/c/interpret.ts
Normal 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;
|
||||
}
|
||||
32
gitnexus/src/core/ingestion/languages/c/merge-bindings.ts
Normal file
32
gitnexus/src/core/ingestion/languages/c/merge-bindings.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
165
gitnexus/src/core/ingestion/languages/c/query.ts
Normal file
165
gitnexus/src/core/ingestion/languages/c/query.ts
Normal 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;
|
||||
}
|
||||
73
gitnexus/src/core/ingestion/languages/c/scope-resolver.ts
Normal file
73
gitnexus/src/core/ingestion/languages/c/scope-resolver.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
38
gitnexus/src/core/ingestion/languages/c/simple-hooks.ts
Normal file
38
gitnexus/src/core/ingestion/languages/c/simple-hooks.ts
Normal 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;
|
||||
}
|
||||
64
gitnexus/src/core/ingestion/languages/c/static-linkage.ts
Normal file
64
gitnexus/src/core/ingestion/languages/c/static-linkage.ts
Normal 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 ?? '';
|
||||
}
|
||||
|
|
@ -71,6 +71,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
|
|||
SupportedLanguages.CSharp,
|
||||
SupportedLanguages.TypeScript,
|
||||
SupportedLanguages.Go,
|
||||
SupportedLanguages.C,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
9
gitnexus/test/fixtures/lang-resolution/c-static-isolation/a.c
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/c-static-isolation/a.c
vendored
Normal 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();
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/c-static-isolation/b.c
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/c-static-isolation/b.c
vendored
Normal 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();
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/c-static-isolation/b.h
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/c-static-isolation/b.h
vendored
Normal 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
|
||||
9
gitnexus/test/fixtures/lang-resolution/c-static-isolation/caller.c
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/c-static-isolation/caller.c
vendored
Normal 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;
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/c-structs/main.c
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/c-structs/main.c
vendored
Normal 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;
|
||||
}
|
||||
20
gitnexus/test/fixtures/lang-resolution/c-structs/service.c
vendored
Normal file
20
gitnexus/test/fixtures/lang-resolution/c-structs/service.c
vendored
Normal 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);
|
||||
}
|
||||
15
gitnexus/test/fixtures/lang-resolution/c-structs/service.h
vendored
Normal file
15
gitnexus/test/fixtures/lang-resolution/c-structs/service.h
vendored
Normal 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
|
||||
18
gitnexus/test/fixtures/lang-resolution/c-structs/user.c
vendored
Normal file
18
gitnexus/test/fixtures/lang-resolution/c-structs/user.c
vendored
Normal 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;
|
||||
}
|
||||
13
gitnexus/test/fixtures/lang-resolution/c-structs/user.h
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/c-structs/user.h
vendored
Normal 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
|
||||
111
gitnexus/test/integration/resolvers/c.test.ts
Normal file
111
gitnexus/test/integration/resolvers/c.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
212
gitnexus/test/unit/scope-resolution/c/c-arity.test.ts
Normal file
212
gitnexus/test/unit/scope-resolution/c/c-arity.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
355
gitnexus/test/unit/scope-resolution/c/c-captures.test.ts
Normal file
355
gitnexus/test/unit/scope-resolution/c/c-captures.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
103
gitnexus/test/unit/scope-resolution/c/c-header-scan.test.ts
Normal file
103
gitnexus/test/unit/scope-resolution/c/c-header-scan.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
166
gitnexus/test/unit/scope-resolution/c/c-imports.test.ts
Normal file
166
gitnexus/test/unit/scope-resolution/c/c-imports.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue