mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
feat: migrate C to scope-based resolution (RFC #909 Ring 3)
Add C ScopeResolver with: - tree-sitter-c scope query (structs, unions, enums, functions, macros, variables, includes) - emitCScopeCaptures with arity enrichment and typedef-struct dedup - interpretCImport for #include directives (system headers filtered) - resolveCImportTarget with suffix matching - cArityCompatibility with variadic detection - cMergeBindings (first-wins by tier) - Header file scanning for cross-language #include resolution - Register in SCOPE_RESOLVERS and MIGRATED_LANGUAGES - Integration test with 4 passing test cases Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/ddcbc075-2999-492c-a0ac-47cddd401a4b Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
This commit is contained in:
parent
4a60e98d0f
commit
96e500512f
15 changed files with 289 additions and 41 deletions
2
gitnexus/package-lock.json
generated
2
gitnexus/package-lock.json
generated
|
|
@ -63,7 +63,7 @@
|
|||
"vitest": "^4.0.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"node-addon-api": "^8.0.0",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ export function emitCScopeCaptures(
|
|||
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) {
|
||||
|
|
@ -43,6 +47,21 @@ export function emitCScopeCaptures(
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
const declAnchor = grouped['@declaration.function'];
|
||||
if (declAnchor !== undefined) {
|
||||
|
|
@ -92,14 +111,5 @@ export function emitCScopeCaptures(
|
|||
out.push(grouped);
|
||||
}
|
||||
|
||||
// Synthesize typeBindings for struct fields (for compound receiver resolution)
|
||||
for (const match of out) {
|
||||
if (match['@declaration.field'] === undefined) continue;
|
||||
const nameCap = match['@declaration.name'];
|
||||
if (nameCap === undefined) continue;
|
||||
// For C, we don't have rich type info on fields from the query
|
||||
// but we keep the slot for future enhancement
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
41
gitnexus/src/core/ingestion/languages/c/header-scan.ts
Normal file
41
gitnexus/src/core/ingestion/languages/c/header-scan.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { readdirSync, statSync } 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: ReturnType<typeof readdirSync>;
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // permission denied, etc.
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
// Skip common non-source directories
|
||||
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'vendor') {
|
||||
continue;
|
||||
}
|
||||
walk(full, root, out);
|
||||
} else if (entry.isFile()) {
|
||||
const ext = entry.name.slice(entry.name.lastIndexOf('.'));
|
||||
if (HEADER_EXTENSIONS.has(ext)) {
|
||||
out.add(relative(root, full));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,27 +8,40 @@ import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/as
|
|||
*/
|
||||
export function splitCInclude(node: SyntaxNode): CaptureMatch | null {
|
||||
// node.type === 'preproc_include'
|
||||
// children: '#include' + (string_literal | system_lib_string)
|
||||
let pathNode: SyntaxNode | null = null;
|
||||
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') {
|
||||
pathNode = child;
|
||||
break;
|
||||
// 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;
|
||||
}
|
||||
if (pathNode === null) return null;
|
||||
return buildIncludeCapture(node, pathNode);
|
||||
}
|
||||
|
||||
// Strip quotes: "foo.h" → foo.h or <stdio.h> → stdio.h
|
||||
let raw = pathNode.text;
|
||||
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith('<') && raw.endsWith('>'))) {
|
||||
raw = raw.slice(1, -1);
|
||||
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: CaptureMatch = {
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -15,16 +15,28 @@ const C_SCOPE_QUERY = `
|
|||
(switch_statement) @scope.block
|
||||
(case_statement) @scope.block
|
||||
|
||||
;; Declarations — struct
|
||||
;; Declarations — struct (named)
|
||||
(struct_specifier
|
||||
name: (type_identifier) @declaration.name
|
||||
body: (field_declaration_list)) @declaration.struct
|
||||
|
||||
;; Declarations — union
|
||||
;; 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
|
||||
|
|
@ -64,16 +76,11 @@ const C_SCOPE_QUERY = `
|
|||
declarator: (pointer_declarator
|
||||
declarator: (field_identifier) @declaration.name)) @declaration.field
|
||||
|
||||
;; Declarations — variables
|
||||
;; Declarations — variables (with initializer)
|
||||
(declaration
|
||||
declarator: (init_declarator
|
||||
declarator: (identifier) @declaration.name)) @declaration.variable
|
||||
|
||||
;; Declarations — plain variable (no initializer)
|
||||
(declaration
|
||||
declarator: (identifier) @declaration.name
|
||||
!declarator) @declaration.variable
|
||||
|
||||
;; Declarations — macro definitions
|
||||
(preproc_def
|
||||
name: (identifier) @declaration.name) @declaration.macro
|
||||
|
|
@ -89,13 +96,9 @@ const C_SCOPE_QUERY = `
|
|||
(preproc_include) @import.statement
|
||||
|
||||
;; Type bindings — parameter annotations
|
||||
(function_definition
|
||||
declarator: (function_declarator
|
||||
declarator: (identifier) @_fn_name
|
||||
parameters: (parameter_list
|
||||
(parameter_declaration
|
||||
declarator: (identifier) @type-binding.name
|
||||
type: (_) @type-binding.type)))) @type-binding.parameter
|
||||
(parameter_declaration
|
||||
type: (_) @type-binding.type
|
||||
declarator: (identifier) @type-binding.name) @type-binding.parameter
|
||||
|
||||
;; Type bindings — variable with type (init_declarator)
|
||||
(declaration
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.
|
|||
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';
|
||||
|
||||
/**
|
||||
* C `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
|
||||
|
|
@ -22,8 +23,20 @@ export const cScopeResolver: ScopeResolver = {
|
|||
languageProvider: cProvider,
|
||||
importEdgeReason: 'c-scope: include',
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths) =>
|
||||
resolveCImportTarget(targetRaw, fromFile, allFilePaths),
|
||||
loadResolutionConfig: (repoPath: string) => 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);
|
||||
},
|
||||
|
||||
mergeBindings: (existing, incoming, scopeId) => cMergeBindings(existing, incoming, scopeId),
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
|
|||
SupportedLanguages.CSharp,
|
||||
SupportedLanguages.TypeScript,
|
||||
SupportedLanguages.Go,
|
||||
SupportedLanguages.C,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
]);
|
||||
|
|
|
|||
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
|
||||
71
gitnexus/test/integration/resolvers/c.test.ts
Normal file
71
gitnexus/test/integration/resolvers/c.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue