From 96e500512f8d0b2b6d8cbd6ac8cb1adec327c4f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 16:11:50 +0000 Subject: [PATCH] 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> --- gitnexus/package-lock.json | 2 +- .../src/core/ingestion/languages/c-cpp.ts | 19 +++++ .../core/ingestion/languages/c/captures.ts | 28 +++++--- .../core/ingestion/languages/c/header-scan.ts | 41 +++++++++++ .../languages/c/import-decomposer.ts | 41 +++++++---- .../src/core/ingestion/languages/c/query.ts | 33 +++++---- .../ingestion/languages/c/scope-resolver.ts | 17 ++++- .../core/ingestion/registry-primary-flag.ts | 1 + .../scope-resolution/pipeline/registry.ts | 2 + .../fixtures/lang-resolution/c-structs/main.c | 9 +++ .../lang-resolution/c-structs/service.c | 20 ++++++ .../lang-resolution/c-structs/service.h | 15 ++++ .../fixtures/lang-resolution/c-structs/user.c | 18 +++++ .../fixtures/lang-resolution/c-structs/user.h | 13 ++++ gitnexus/test/integration/resolvers/c.test.ts | 71 +++++++++++++++++++ 15 files changed, 289 insertions(+), 41 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/c/header-scan.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/c-structs/main.c create mode 100644 gitnexus/test/fixtures/lang-resolution/c-structs/service.c create mode 100644 gitnexus/test/fixtures/lang-resolution/c-structs/service.h create mode 100644 gitnexus/test/fixtures/lang-resolution/c-structs/user.c create mode 100644 gitnexus/test/fixtures/lang-resolution/c-structs/user.h create mode 100644 gitnexus/test/integration/resolvers/c.test.ts diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index dfc5e3c4c..7aa9dc7bf 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -63,7 +63,7 @@ "vitest": "^4.0.18" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "optionalDependencies": { "node-addon-api": "^8.0.0", diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 693e8cec2..7fab4689b 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -46,6 +46,15 @@ import { createCallExtractor } from '../call-extractors/generic.js'; import { cCallConfig, cppCallConfig } from '../call-extractors/configs/c-cpp.js'; import { createHeritageExtractor } from '../heritage-extractors/generic.js'; import { stripUeMacros } from '../cpp-ue-preprocessor.js'; +import { + emitCScopeCaptures, + interpretCImport, + interpretCTypeBinding, + cArityCompatibility, + cBindingScopeFor, + cImportOwningScope, + cReceiverBinding, +} from './c/index.js'; const C_BUILT_INS: ReadonlySet = new Set([ 'printf', @@ -367,6 +376,16 @@ export const cProvider = defineLanguage({ heritageExtractor: createHeritageExtractor(SupportedLanguages.C), labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, + + // ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ────────── + emitScopeCaptures: emitCScopeCaptures, + interpretImport: interpretCImport, + interpretTypeBinding: interpretCTypeBinding, + bindingScopeFor: cBindingScopeFor, + importOwningScope: cImportOwningScope, + receiverBinding: cReceiverBinding, + arityCompatibility: cArityCompatibility, + // mergeBindings + resolveImportTarget live on ScopeResolver (see c/scope-resolver.ts). }); export const cppProvider = defineLanguage({ diff --git a/gitnexus/src/core/ingestion/languages/c/captures.ts b/gitnexus/src/core/ingestion/languages/c/captures.ts index d4b1d935b..c29417366 100644 --- a/gitnexus/src/core/ingestion/languages/c/captures.ts +++ b/gitnexus/src/core/ingestion/languages/c/captures.ts @@ -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(); + for (const m of rawMatches) { const grouped: Record = {}; 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; } diff --git a/gitnexus/src/core/ingestion/languages/c/header-scan.ts b/gitnexus/src/core/ingestion/languages/c/header-scan.ts new file mode 100644 index 000000000..6dc7d3903 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/header-scan.ts @@ -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 { + const headers = new Set(); + walk(repoPath, repoPath, headers); + return headers; +} + +function walk(dir: string, root: string, out: Set): void { + let entries: ReturnType; + 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)); + } + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/c/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/c/import-decomposer.ts index ea2db5fca..5cef27430 100644 --- a/gitnexus/src/core/ingestion/languages/c/import-decomposer.ts +++ b/gitnexus/src/core/ingestion/languages/c/import-decomposer.ts @@ -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 - 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: → 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 = { '@import.statement': nodeToCapture('@import.statement', node), '@import.kind': syntheticCapture('@import.kind', node, 'wildcard'), '@import.source': syntheticCapture('@import.source', node, raw), diff --git a/gitnexus/src/core/ingestion/languages/c/query.ts b/gitnexus/src/core/ingestion/languages/c/query.ts index 7db4c520a..3d439dd7a 100644 --- a/gitnexus/src/core/ingestion/languages/c/query.ts +++ b/gitnexus/src/core/ingestion/languages/c/query.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts index bbce1c42b..f4d1a156e 100644 --- a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts @@ -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 | 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), diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 713b32c07..e063ce20c 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -71,6 +71,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set = n [SupportedLanguages.CSharp, csharpScopeResolver], [SupportedLanguages.TypeScript, typescriptScopeResolver], [SupportedLanguages.Go, goScopeResolver], + [SupportedLanguages.C, cScopeResolver], ]); diff --git a/gitnexus/test/fixtures/lang-resolution/c-structs/main.c b/gitnexus/test/fixtures/lang-resolution/c-structs/main.c new file mode 100644 index 000000000..334f36b16 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-structs/main.c @@ -0,0 +1,9 @@ +#include "service.h" + +int main(void) { + struct Service *svc = create_service(); + service_add_user(svc, "Alice", 25); + service_add_user(svc, "Bob", 32); + destroy_service(svc); + return 0; +} diff --git a/gitnexus/test/fixtures/lang-resolution/c-structs/service.c b/gitnexus/test/fixtures/lang-resolution/c-structs/service.c new file mode 100644 index 000000000..85c4cdef6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-structs/service.c @@ -0,0 +1,20 @@ +#include "service.h" +#include + +struct Service *create_service(void) { + struct Service *svc = malloc(sizeof(struct Service)); + svc->admin = create_user("admin", 30); + svc->user_count = 0; + return svc; +} + +void service_add_user(struct Service *svc, const char *name, int age) { + struct User *user = create_user(name, age); + svc->user_count++; + free_user(user); +} + +void destroy_service(struct Service *svc) { + free_user(svc->admin); + free(svc); +} diff --git a/gitnexus/test/fixtures/lang-resolution/c-structs/service.h b/gitnexus/test/fixtures/lang-resolution/c-structs/service.h new file mode 100644 index 000000000..a5056edc7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-structs/service.h @@ -0,0 +1,15 @@ +#ifndef SERVICE_H +#define SERVICE_H + +#include "user.h" + +struct Service { + struct User *admin; + int user_count; +}; + +struct Service *create_service(void); +void service_add_user(struct Service *svc, const char *name, int age); +void destroy_service(struct Service *svc); + +#endif diff --git a/gitnexus/test/fixtures/lang-resolution/c-structs/user.c b/gitnexus/test/fixtures/lang-resolution/c-structs/user.c new file mode 100644 index 000000000..2f3654913 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-structs/user.c @@ -0,0 +1,18 @@ +#include "user.h" +#include +#include + +struct User *create_user(const char *name, int age) { + struct User *user = malloc(sizeof(struct User)); + strncpy(user->name, name, sizeof(user->name) - 1); + user->age = age; + return user; +} + +void free_user(struct User *user) { + free(user); +} + +int get_user_age(const struct User *user) { + return user->age; +} diff --git a/gitnexus/test/fixtures/lang-resolution/c-structs/user.h b/gitnexus/test/fixtures/lang-resolution/c-structs/user.h new file mode 100644 index 000000000..6e14c2425 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-structs/user.h @@ -0,0 +1,13 @@ +#ifndef USER_H +#define USER_H + +struct User { + char name[64]; + int age; +}; + +struct User *create_user(const char *name, int age); +void free_user(struct User *user); +int get_user_age(const struct User *user); + +#endif diff --git a/gitnexus/test/integration/resolvers/c.test.ts b/gitnexus/test/integration/resolvers/c.test.ts new file mode 100644 index 000000000..fc82ed83d --- /dev/null +++ b/gitnexus/test/integration/resolvers/c.test.ts @@ -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'); + }); +});