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:
copilot-swe-agent[bot] 2026-05-10 16:11:50 +00:00 • committed by GitHub
parent 4a60e98d0f
commit 96e500512f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 289 additions and 41 deletions

View file

@ -63,7 +63,7 @@
"vitest": "^4.0.18"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
},
"optionalDependencies": {
"node-addon-api": "^8.0.0",

View file

@ -46,6 +46,15 @@ import { createCallExtractor } from '../call-extractors/generic.js';
import { cCallConfig, cppCallConfig } from '../call-extractors/configs/c-cpp.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import { stripUeMacros } from '../cpp-ue-preprocessor.js';
import {
emitCScopeCaptures,
interpretCImport,
interpretCTypeBinding,
cArityCompatibility,
cBindingScopeFor,
cImportOwningScope,
cReceiverBinding,
} from './c/index.js';
const C_BUILT_INS: ReadonlySet<string> = new Set([
'printf',
@ -367,6 +376,16 @@ export const cProvider = defineLanguage({
heritageExtractor: createHeritageExtractor(SupportedLanguages.C),
labelOverride: cppLabelOverride,
builtInNames: C_BUILT_INS,
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
emitScopeCaptures: emitCScopeCaptures,
interpretImport: interpretCImport,
interpretTypeBinding: interpretCTypeBinding,
bindingScopeFor: cBindingScopeFor,
importOwningScope: cImportOwningScope,
receiverBinding: cReceiverBinding,
arityCompatibility: cArityCompatibility,
// mergeBindings + resolveImportTarget live on ScopeResolver (see c/scope-resolver.ts).
});
export const cppProvider = defineLanguage({

View file

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

View 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));
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -15,6 +15,7 @@ import { pythonScopeResolver } from '../../languages/python/scope-resolver.js';
import { csharpScopeResolver } from '../../languages/csharp/scope-resolver.js';
import { typescriptScopeResolver } from '../../languages/typescript/scope-resolver.js';
import { goScopeResolver } from '../../languages/go/scope-resolver.js';
import { cScopeResolver } from '../../languages/c/scope-resolver.js';
/** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
@ -28,4 +29,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.CSharp, csharpScopeResolver],
[SupportedLanguages.TypeScript, typescriptScopeResolver],
[SupportedLanguages.Go, goScopeResolver],
[SupportedLanguages.C, cScopeResolver],
]);

View file

@ -0,0 +1,9 @@
#include "service.h"
int main(void) {
struct Service *svc = create_service();
service_add_user(svc, "Alice", 25);
service_add_user(svc, "Bob", 32);
destroy_service(svc);
return 0;
}

View file

@ -0,0 +1,20 @@
#include "service.h"
#include <stdlib.h>
struct Service *create_service(void) {
struct Service *svc = malloc(sizeof(struct Service));
svc->admin = create_user("admin", 30);
svc->user_count = 0;
return svc;
}
void service_add_user(struct Service *svc, const char *name, int age) {
struct User *user = create_user(name, age);
svc->user_count++;
free_user(user);
}
void destroy_service(struct Service *svc) {
free_user(svc->admin);
free(svc);
}

View file

@ -0,0 +1,15 @@
#ifndef SERVICE_H
#define SERVICE_H
#include "user.h"
struct Service {
struct User *admin;
int user_count;
};
struct Service *create_service(void);
void service_add_user(struct Service *svc, const char *name, int age);
void destroy_service(struct Service *svc);
#endif

View file

@ -0,0 +1,18 @@
#include "user.h"
#include <stdlib.h>
#include <string.h>
struct User *create_user(const char *name, int age) {
struct User *user = malloc(sizeof(struct User));
strncpy(user->name, name, sizeof(user->name) - 1);
user->age = age;
return user;
}
void free_user(struct User *user) {
free(user);
}
int get_user_age(const struct User *user) {
return user->age;
}

View file

@ -0,0 +1,13 @@
#ifndef USER_H
#define USER_H
struct User {
char name[64];
int age;
};
struct User *create_user(const char *name, int age);
void free_user(struct User *user);
int get_user_age(const struct User *user);
#endif

View file

@ -0,0 +1,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');
});
});