diff --git a/gitnexus/src/core/ingestion/languages/c/header-scan.ts b/gitnexus/src/core/ingestion/languages/c/header-scan.ts index f74d3fa89..033bfd9eb 100644 --- a/gitnexus/src/core/ingestion/languages/c/header-scan.ts +++ b/gitnexus/src/core/ingestion/languages/c/header-scan.ts @@ -27,8 +27,21 @@ function walk(dir: string, root: string, out: Set): void { const name = entry.name; const full = join(dir, name); if (entry.isDirectory()) { - // Skip common non-source directories - if (name === 'node_modules' || name === '.git' || name === 'vendor') { + // 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); diff --git a/gitnexus/src/core/ingestion/languages/c/import-target.ts b/gitnexus/src/core/ingestion/languages/c/import-target.ts index a90da6216..4af025334 100644 --- a/gitnexus/src/core/ingestion/languages/c/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/c/import-target.ts @@ -1,26 +1,41 @@ +import { dirname, join } from 'path'; + /** * Resolve a C #include path to a file in the workspace. * - * Strategy: match the include path suffix against all file paths in - * the workspace. "foo.h" matches "src/foo.h", "include/foo.h", etc. - * For paths with directory components ("dir/foo.h"), match the full - * relative suffix. - * - * 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. + * 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, + fromFile: string, allFilePaths: ReadonlySet, ): string | null { if (!targetRaw) return null; const normalizedTarget = targetRaw.replace(/\\/g, '/'); - // Exact match first + // 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; + // Also try normalized target in case targetRaw has slashes + const siblingAlt = join(dirname(fromFile), normalizedTarget); + const siblingAltNorm = siblingAlt.replace(/\\/g, '/'); + if (siblingAltNorm !== sibling && 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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index d63c3906a..44f941daf 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -141,6 +141,12 @@ function pickUniqueGlobalCallable( const seen = new Set(); 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); diff --git a/gitnexus/test/unit/scope-resolution/c/c-header-scan.test.ts b/gitnexus/test/unit/scope-resolution/c/c-header-scan.test.ts new file mode 100644 index 000000000..1543db006 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/c/c-header-scan.test.ts @@ -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'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/c/c-imports.test.ts b/gitnexus/test/unit/scope-resolution/c/c-imports.test.ts index 274274ef4..7496502f6 100644 --- a/gitnexus/test/unit/scope-resolution/c/c-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/c/c-imports.test.ts @@ -128,4 +128,43 @@ describe('C import target resolution (resolveCImportTarget)', () => { 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'); + }); });