From 86a39202cf5285f79ca1081b7087bee16e9500cb Mon Sep 17 00:00:00 2001 From: Sravan Avvaru <81159574+Sravan1011@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:10:54 +0530 Subject: [PATCH] fix(#2965): system headers must not resolve to in-repo files (#3341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#2965): system headers must not resolve to in-repo files C and C++ use two syntactically distinct include forms: #include -- angle-bracket: search system include paths only #include "x.h" -- quoted: search relative to the including file first The old suffix-match fallback in resolveCImportTarget had no awareness of this distinction, so a repo containing its own stdio.h would capture every #include and resolve it to the local file. Fix: - Add isSystem?: boolean to the wildcard variant of ParsedImportSyntax - interpretCImport / interpretCppImport now set isSystem from the @import.system tree-sitter capture (present for angle-bracket form) - resolveImportTarget in both cScopeResolver and cppScopeResolver short-circuits to null when context.parsedImport.isSystem is true, refusing to suffix-match system headers against workspace files - Remove C and C++ from KNOWN_GAPS in the conformance test; add proper test cases with a parsedImport factory that distinguishes angle-bracket (isSystem:true) from quoted (isSystem:false) includes Test: all 36 external-import-conformance cases pass, including the two new c/cpp arms that were previously in KNOWN_GAPS. * fix(#2965): update cpp-imports unit tests for isSystem field Two test assertions were broken by the interpreter change: 1. Local include: the wildcard ParsedImport now always includes isSystem (false for quoted includes). Updated expected object to include isSystem:false. 2. System header: the old test asserted interpretCppImport returned null for system headers. The refactored design moves the null decision to the resolver layer (cppScopeResolver.resolveImportTarget) so the call graph and resolution stay separate concerns. The interpreter now returns { kind:'wildcard', isSystem:true } and the test name/assertion are updated to reflect this. * fix(#2965): resolve C and C++ includes on search paths Angle includes follow each translation unit's include roots, so a local stdio.h no longer captures system headers or another file's -I list. Co-authored-by: Cursor * Address PR review feedback (#3341) - Accept in-repo include roots whose names start with `..` while still rejecting parent escapes. - Correct the import-target bench comments so CONTEXT_LANGS and newPass match C/C++ header passes. Co-authored-by: Cursor * fix(c,cpp): read include config per directory for monorepos (#2965) Angle includes now resolve only against declared search roots, so config read only at the repo root left monorepo sub-projects with nothing declared. - compile_commands.json, compile_flags.txt, .ccls, .clangd and c_cpp_properties.json are read in every directory; a file takes the nearest one, and a nearer database's entry beats a shallower one (clangd). - CMake include_directories / target_include_directories are read: directory-scoped and PRIVATE roots reach their subtree, PUBLIC and INTERFACE roots reach every file. ${CMAKE_CURRENT_SOURCE_DIR} and friends expand; unresolvable variables and generator expressions are dropped. - Declared roots win: implicit include/Headers/inc roots apply only when no config speaks for the file, and never to a database-listed file. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(c,cpp): give CMake PUBLIC includes only to linked targets (#3341) target_link_libraries now decides who sees PUBLIC and INTERFACE roots, so an unrelated target no longer resolves another package's headers. --------- Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar Co-authored-by: Cursor Co-authored-by: Claude Opus 5.5 (1M context) --- gitnexus-shared/src/scope-resolution/types.ts | 6 + gitnexus/bench/import-target/baselines.json | 10 + gitnexus/bench/import-target/measure.mjs | 183 +++- .../core/ingestion/languages/c/header-scan.ts | 57 +- .../ingestion/languages/c/import-target.ts | 254 ++++- .../core/ingestion/languages/c/interpret.ts | 9 +- .../languages/c/resolution-config.ts | 955 ++++++++++++++++++ .../ingestion/languages/c/scope-resolver.ts | 39 +- .../ingestion/languages/cpp/header-scan.ts | 52 +- .../ingestion/languages/cpp/import-target.ts | 22 +- .../core/ingestion/languages/cpp/interpret.ts | 9 +- .../ingestion/languages/cpp/scope-resolver.ts | 39 +- gitnexus/src/storage/parse-cache.ts | 5 +- .../test/unit/incremental-parse-cache.test.ts | 9 +- .../unit/scope-resolution/c/c-imports.test.ts | 30 +- .../scope-resolution/c/c-workspace.test.ts | 670 ++++++++++++ .../scope-resolution/cpp/cpp-imports.test.ts | 10 +- .../external-import-conformance.test.ts | 31 +- ...import-target-index-reuse.contract.test.ts | 4 +- 19 files changed, 2163 insertions(+), 231 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/c/resolution-config.ts create mode 100644 gitnexus/test/unit/scope-resolution/c/c-workspace.test.ts diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 50a968146..5bad9673b 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -379,6 +379,12 @@ type ParsedImportSyntax = * deferred — `use` does not execute * (`LanguageProvider.importsExecuteWhereWritten`). */ readonly runsOnlyWhenCalled?: boolean; + /** + * C/C++ `#include <…>` (true) versus `#include "…"`. + * Angle includes resolve only on header search paths. Quoted includes + * may still use the including file's directory and the basename index. + */ + readonly isSystem?: boolean; } /** * Runtime-computed target — the import path is not a static literal at diff --git a/gitnexus/bench/import-target/baselines.json b/gitnexus/bench/import-target/baselines.json index 317f56898..006014d18 100644 --- a/gitnexus/bench/import-target/baselines.json +++ b/gitnexus/bench/import-target/baselines.json @@ -981,6 +981,11 @@ "depth_ratio": 1.938, "scaling_ratio": 1.033, "small_ms": 1.649 + }, + "context": { + "target": "stdio.h", + "with_context": "", + "without_context": "src/stdio.h" } }, "cpp": { @@ -1032,6 +1037,11 @@ "depth_ratio": 2.064, "scaling_ratio": 1.167, "small_ms": 1.626 + }, + "context": { + "target": "cstdio.h", + "with_context": "", + "without_context": "src/cstdio.h" } }, "objc": { diff --git a/gitnexus/bench/import-target/measure.mjs b/gitnexus/bench/import-target/measure.mjs index 6290a8893..2690e9e48 100644 --- a/gitnexus/bench/import-target/measure.mjs +++ b/gitnexus/bench/import-target/measure.mjs @@ -105,12 +105,14 @@ * path components, so the `deep` arm's uniform prefix reaches the * config (see `tsBaseUrlFor`) and its cost is the same keyed lookup the * other arms pay. - * - c, cpp: `resolveCppImportTarget` delegates to `resolveCImportTarget`, so - * the two share a resolver and differ in extension set and in which adapter - * builds the augmented set. Cost is a basename bucket walk with a - * depth-then-lexicographic tie-break, so the collide arm (a `mod{n}` header - * in every service's `include/`) is where it grows: 2.54 / 2.64 against - * 1.06 on file count. + * - c, cpp: quoted includes walk a basename bucket (depth, then lexicographic). + * The collide arm — a `mod{n}` header in every service's `include/` — is + * where that walk grows: 2.54 / 2.64 against 1.06 on file count. Each + * language keeps its own bucket memo. Angle includes join the target onto + * header search paths and never take that walk. The timing corpus passes + * a raw header set and no `isSystem` flag, so it stays on the quoted path. + * `--check` also resolves a quoted hit, an angle hit on a declared include + * root, and an angle miss against a same-named `src/` decoy. * - zig: `resolveZigImportInternal` is rust's shape — an `@import("…zig")` * path is walked component by component from the importer's directory and * probed with two `allFiles.has(...)` calls (as written, then `+ '.zig'`), @@ -618,18 +620,19 @@ const HEAP_BUDGETED = [ // were about. /** - * The arms handed the fifth `context` argument — `{ parsedFiles, parsedImport }` - * — because their registered hook DECLARES it. Four of seventeen arms, and the - * inventory arm at the foot of this file reconciles that claim against - * `SCOPE_RESOLVERS` in both directions rather than trusting this line. + * Arms whose registered hook declares the fifth `context` argument + * (`{ parsedFiles, parsedImport }`). The inventory at the foot of this file + * reconciles this list against `SCOPE_RESOLVERS` in both directions, so + * membership is that check rather than a count written here. * - * These are also the only arms for which `newPass` builds a `ParsedFile[]` at - * all. Building one for the other thirteen would cost their timed loop an - * O(files) allocation per pass that no resolver of theirs can even observe — - * their hooks declare three or four parameters — so their numbers stay exactly - * where they were. + * `newPass` builds a `ParsedFile[]` only for the members that are not header + * languages. C and C++ are listed because they read `parsedImport.isSystem`, + * but `newPass` returns on `HEADER_EXTENSION` before this list, so a timed + * pass does not allocate that array for them. Arms whose hooks declare fewer + * parameters cannot observe a context and are not listed, so their timed + * numbers stay on the three-argument shape. */ -const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift']; +const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift', 'c', 'cpp']; /** * Needs `node --expose-gc` to force collection for a clean delta; without it @@ -1732,8 +1735,11 @@ function buildRepo(lang, fileCount, pad = 0, shape = 'unique') { * `csharp_csproj` is the precedent and stays where it is: a per-language * CONTEXT over a corpus aliased to another language's, rather than a new axis. * - * `parsedFiles` is the third pass-stable object, present for `CONTEXT_LANGS` - * and undefined for everyone else. It is built BEFORE the path set and the path + * `parsedFiles` is the third pass-stable object. `newPass` builds it for the + * `CONTEXT_LANGS` members that are not header languages, and leaves it + * undefined otherwise — C and C++ are in that list but return on + * `HEADER_EXTENSION` first, so a timed pass does not allocate the array. + * Where it is built, it is built BEFORE the path set and the path * set is derived FROM it, which is not a stylistic choice: `run.ts` does * `new Set(parsedFiles.map((f) => f.filePath))`, so two independently built * lists would be a shape the pipeline cannot produce. Fresh per pass for @@ -1930,10 +1936,22 @@ function resolveOne(lang, from, target, pass) { return typescriptScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config); } if (lang === 'c') { - return cScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config); + return cScopeResolver.resolveImportTarget( + target, + from, + allFilePaths, + pass.config, + pass.includeContext, + ); } if (lang === 'cpp') { - return cppScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config); + return cppScopeResolver.resolveImportTarget( + target, + from, + allFilePaths, + pass.config, + pass.includeContext, + ); } if (lang === 'objc') { return objectiveCScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config); @@ -2364,6 +2382,30 @@ const CONTEXT_PROBE = { probeFile('Sources/Client/Main.swift', [['Class', 'Client.Main']]), ], }, + /** + * `#include ` with a repo file `src/stdio.h`. With the fifth + * argument the include is an angle include and the decoy is not on a + * search path, so the answer is null. Without it the call is the quoted + * basename walk and the decoy wins. Same shape for C++ `cstdio.h`. + */ + c: { + from: 'src/main.c', + target: 'stdio.h', + parsedFiles: [ + probeFile('src/main.c', []), + probeFile('src/stdio.h', []), + probeFile('include/util.h', []), + ], + }, + cpp: { + from: 'src/main.cpp', + target: 'cstdio.h', + parsedFiles: [ + probeFile('src/main.cpp', []), + probeFile('src/cstdio.h', []), + probeFile('include/util.hpp', []), + ], + }, }; /** Resolve the probe twice through `resolveOne` — once with the pass's parsed @@ -2375,9 +2417,14 @@ function measureContext(lang) { const config = lang === 'php' ? phpComposerConfigFor(0) : undefined; const answer = (files) => { restoreBenchmarkSideChannels(lang, files ?? []); - return renderResolved( - resolveOne(lang, from, target, { allFilePaths, config, parsedFiles: files }), - ); + const pass = { allFilePaths, config, parsedFiles: files }; + if ((lang === 'c' || lang === 'cpp') && files !== undefined) { + pass.includeContext = { + parsedFiles: files, + parsedImport: { kind: 'wildcard', targetRaw: target, isSystem: true }, + }; + } + return renderResolved(resolveOne(lang, from, target, pass)); }; return { target, @@ -2386,6 +2433,85 @@ function measureContext(lang) { }; } +/** + * Quote vs angle for C and C++, on top of the scaling arms. The timing corpus + * stays quoted (no `isSystem`) so its fingerprints do not move. This is the + * arm that fails if angle includes go back to a repo-wide name hunt, or if a + * raw header set stops resolving a quoted include. + */ +function checkCIncludeForms(failures) { + const arms = [ + { + lang: 'c', + resolver: cScopeResolver, + from: 'src/main.c', + files: ['src/main.c', 'src/stdio.h', 'include/util.h'], + quoted: 'util.h', + quotedHit: 'include/util.h', + angleHit: 'util.h', + angleHitFile: 'include/util.h', + angleMiss: 'stdio.h', + }, + { + lang: 'cpp', + resolver: cppScopeResolver, + from: 'src/main.cpp', + files: ['src/main.cpp', 'src/cstdio.h', 'include/util.hpp'], + quoted: 'util.hpp', + quotedHit: 'include/util.hpp', + angleHit: 'util.hpp', + angleHitFile: 'include/util.hpp', + angleMiss: 'cstdio.h', + }, + ]; + for (const arm of arms) { + const allFilePaths = new Set(arm.files); + const config = { + headers: new Set(arm.files.filter((file) => file !== arm.from)), + headerSearchPaths: ['include'], + userHeaderSearchPaths: [], + }; + const resolve = (target, isSystem) => + arm.resolver.resolveImportTarget(target, arm.from, allFilePaths, config, { + parsedFiles: [], + parsedImport: { kind: 'wildcard', targetRaw: target, isSystem }, + }); + const quotedResult = resolve(arm.quoted, false); + const angledResult = resolve(arm.angleHit, true); + const missedResult = resolve(arm.angleMiss, true); + if (quotedResult !== arm.quotedHit) { + failures.push( + `${arm.lang}: quoted "${arm.quoted}" resolved to ${JSON.stringify(quotedResult)}, ` + + `expected ${arm.quotedHit}`, + ); + } + if (angledResult !== arm.angleHitFile) { + failures.push( + `${arm.lang}: angle <${arm.angleHit}> resolved to ${JSON.stringify(angledResult)}, ` + + `expected ${arm.angleHitFile} on the declared include root`, + ); + } + if (missedResult !== null) { + failures.push( + `${arm.lang}: angle <${arm.angleMiss}> resolved to ${JSON.stringify(missedResult)}; ` + + `a same-named file outside the include root must miss`, + ); + } + const raw = arm.resolver.resolveImportTarget( + arm.quoted, + arm.from, + new Set([arm.from]), + new Set(arm.files.filter((file) => file !== arm.from)), + ); + if (raw !== arm.quotedHit) { + failures.push( + `${arm.lang}: a raw header set resolved quoted "${arm.quoted}" to ${JSON.stringify(raw)}, ` + + `expected ${arm.quotedHit}`, + ); + } + } +} + function fingerprint(outcomes) { return crypto .createHash('sha256') @@ -2556,6 +2682,7 @@ if (!CHECK) { const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8')); const failures = []; +checkCIncludeForms(failures); /** * PRESENCE, for one budget, in the one place that spells the reason. @@ -3102,10 +3229,12 @@ expectNoOrphanKeys( // The SAME reconciliation for `CONTEXT_LANGS`, against the registry rather than // against a claim in a comment. `run.ts` passes the fifth argument to every // provider; which ones can OBSERVE it is decided by how many parameters each -// hook declares, and that is a number the registry can be asked for. Today -// exactly five answer 5 (php, java, kotlin, python, swift) and the other twelve answer 3 or 4 — -// which is why thirteen arms can ignore this whole question and their numbers -// did not move when it was fixed. +// hook declares, and that is a number the registry can be asked for. php, +// java, kotlin, python, and swift read the parsed workspace; c and cpp read +// `parsedImport.isSystem` and nothing else on that object. Their timed passes +// still return before any parsed-file build (see `newPass`), so the fifth +// argument shows up here as the include-form probe, not as a cost on the +// scaling arms. // // `Function.length` stops at the first defaulted or rest parameter, so a hook // written as `(a, b, c, d, context = {})` would read 4 and slip past this arm. diff --git a/gitnexus/src/core/ingestion/languages/c/header-scan.ts b/gitnexus/src/core/ingestion/languages/c/header-scan.ts index 034e27f4d..a1f67c66e 100644 --- a/gitnexus/src/core/ingestion/languages/c/header-scan.ts +++ b/gitnexus/src/core/ingestion/languages/c/header-scan.ts @@ -1,58 +1,9 @@ -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']); +import { C_HEADER_EXTENSIONS, scanCFamilyHeaders } from './resolution-config.js'; /** - * 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). + * Walk `repoPath` and return relative paths of all `.h` files. + * Search paths stay on the workspace config; this is only the header set. */ 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: 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, '/')); - } - } - } + return scanCFamilyHeaders(repoPath, C_HEADER_EXTENSIONS).headers; } diff --git a/gitnexus/src/core/ingestion/languages/c/import-target.ts b/gitnexus/src/core/ingestion/languages/c/import-target.ts index 5590bb2e6..07f79df3c 100644 --- a/gitnexus/src/core/ingestion/languages/c/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/c/import-target.ts @@ -1,5 +1,11 @@ import { dirname, join } from 'path'; import { perFileSet } from '../../import-resolvers/per-file-set.js'; +import { + collapseRepoPath, + nearestScope, + normalizeRepoPath, + type CTranslationUnitPaths, +} from './resolution-config.js'; /** * A workspace file path pre-decomposed for the suffix-match fallback: @@ -7,64 +13,120 @@ import { perFileSet } from '../../import-resolvers/per-file-set.js'; * contract); `normalized` and `depth` are precomputed so the hot path does no * per-element regex/`split`. */ -interface CSuffixCandidate { +export interface CSuffixCandidate { original: string; normalized: string; depth: number; } +export type CIncludeSuffixIndex = ( + allFilePaths: ReadonlySet, +) => Map; + /** - * Per-pass memo: workspace paths bucketed by basename (last path segment), - * keyed on the `allFilePaths` set identity. + * Basename buckets for quoted `#include "…"` suffix fallback. * - * `resolveCImportTarget` is called once per (quoted) C/C++ `#include` with the - * same `allFilePaths` set per pass (the augmented set is itself memoized in - * the C resolver). The old suffix-match fallback scanned ALL workspace paths - * per include — with a per-element `.replace`/`.split` and no early exit - * (the fewest-path-components tie-break forces a full scan) — i.e. - * O(R_suffix × (F+H)). A path can satisfy `endsWith('/'+target)` (or equal - * the target) ONLY IF its basename equals the target's last segment, so we - * pre-bucket by basename once (O(F+H), `normalized`/`depth` precomputed) and - * the fallback inspects a single small bucket → O(F+H) build + ~O(1)/include. - * `WeakMap`-keyed so it is reclaimed with the pass (no cross-pass staleness). - * Shared by C and C++ (`resolveCppImportTarget` delegates here). + * The builder is shared. Each language keeps its own memo: C++ calls + * `resolveCFamilyImport` with the index it created here, so a C pass and a + * C++ pass never read each other's map even when handed the same set. */ -const suffixIndex = perFileSet((allFilePaths: ReadonlySet) => { - const index = new Map(); - for (const original of allFilePaths) { - const normalized = original.replace(/\\/g, '/'); - const basename = normalized.slice(normalized.lastIndexOf('/') + 1); - let bucket = index.get(basename); - if (bucket === undefined) { - bucket = []; - index.set(basename, bucket); +export function createCIncludeSuffixIndex(): CIncludeSuffixIndex { + return perFileSet((allFilePaths: ReadonlySet) => { + const index = new Map(); + for (const original of allFilePaths) { + const normalized = original.replace(/\\/g, '/'); + const basename = normalized.slice(normalized.lastIndexOf('/') + 1); + let bucket = index.get(basename); + if (bucket === undefined) { + bucket = []; + index.set(basename, bucket); + } + bucket.push({ original, normalized, depth: normalized.split('/').length }); } - bucket.push({ original, normalized, depth: normalized.split('/').length }); - } - return index; -}); + return index; + }); +} + +const cSuffixIndex = createCIncludeSuffixIndex(); + +/** Optional search roots. Absent means "quoted include, no declared paths". */ +export interface CIncludeLookup { + /** `#include <…>`. Never suffix-matches the workspace. */ + readonly isSystem?: boolean; + /** Fallback `-I` / `-isystem` / `/I` and implicit roots. */ + readonly headerSearchPaths?: readonly string[]; + /** Fallback `-iquote`. Quoted includes only. */ + readonly userHeaderSearchPaths?: readonly string[]; + /** + * Per translation unit. A hit replaces the fallback lists for that file. + * The map is the one built at config load — lookup does not copy it. + */ + readonly translationUnits?: ReadonlyMap; + /** Nearest-ancestor lists for a file with no database entry. */ + readonly directoryScopes?: ReadonlyMap; + /** Interface include roots from `target_link_libraries`, keyed by source file. */ + readonly cmakeLinkHeaders?: ReadonlyMap; +} /** * 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. + * Quoted `#include "…"` (the default when `lookup` is omitted, which is + * what the import-target bench and the older unit tests call): + * 1. Same directory as the including file + * 2. User search paths, then header search paths + * 3. Exact path, then the basename bucket + * + * Angle `#include <…>` (`lookup.isSystem`): join the target onto each + * header search path and return that file, or null. A same-named header + * under `src/` is not a candidate when `src` is not a search path. */ +export function cIncludeLookupFromConfig( + config: + | { + readonly headerSearchPaths?: readonly string[]; + readonly userHeaderSearchPaths?: readonly string[]; + readonly translationUnits?: ReadonlyMap; + readonly directoryScopes?: ReadonlyMap; + readonly cmakeLinkHeaders?: ReadonlyMap; + } + | undefined, + isSystem: boolean, +): CIncludeLookup { + return { + isSystem, + headerSearchPaths: config?.headerSearchPaths, + userHeaderSearchPaths: config?.userHeaderSearchPaths, + translationUnits: config?.translationUnits, + directoryScopes: config?.directoryScopes, + cmakeLinkHeaders: config?.cmakeLinkHeaders, + }; +} + export function resolveCImportTarget( targetRaw: string, fromFile: string, allFilePaths: ReadonlySet, + lookup?: CIncludeLookup, +): string | null { + return resolveCFamilyImport(targetRaw, fromFile, allFilePaths, lookup, cSuffixIndex); +} + +export function resolveCFamilyImport( + targetRaw: string, + fromFile: string, + allFilePaths: ReadonlySet, + lookup: CIncludeLookup | undefined, + suffixIndex: CIncludeSuffixIndex, ): string | null { if (!targetRaw) return null; const normalizedTarget = targetRaw.replace(/\\/g, '/'); + const lists = listsFor(lookup, fromFile); + + if (lookup?.isSystem === true) { + return matchSearchPaths(normalizedTarget, lists.header, allFilePaths); + } // Same-directory sibling first: mirrors the C compiler's #include "…" // relative-lookup semantics where the directory of the including @@ -82,6 +144,15 @@ export function resolveCImportTarget( } } + const onUserPath = + lists.user.length === 0 ? null : matchSearchPaths(normalizedTarget, lists.user, allFilePaths); + if (onUserPath !== null) return onUserPath; + const onHeaderPath = + lists.header.length === 0 + ? null + : matchSearchPaths(normalizedTarget, lists.header, allFilePaths); + if (onHeaderPath !== null) return onHeaderPath; + // Exact match (path as-is in the workspace) if (allFilePaths.has(normalizedTarget)) return normalizedTarget; @@ -116,3 +187,114 @@ export function resolveCImportTarget( return bestMatch; } + +function listsFor( + lookup: CIncludeLookup | undefined, + fromFile: string, +): { readonly header: readonly string[]; readonly user: readonly string[] } { + const units = lookup?.translationUnits; + if (units !== undefined && units.size > 0) { + const exact = units.get(normalizeRepoPath(fromFile)); + if (exact !== undefined) { + return withCmakeLinks(lookup, fromFile, { + header: exact.headerSearchPaths, + user: exact.userHeaderSearchPaths, + }); + } + const near = pathsFromTranslationUnits(units, fromFile); + if (near !== undefined) return withCmakeLinks(lookup, fromFile, near); + } + const scopes = lookup?.directoryScopes; + const paths = + scopes !== undefined && scopes.size > 0 ? nearestScope(scopes, fromFile) : undefined; + if (paths !== undefined) { + return withCmakeLinks(lookup, fromFile, { + header: paths.headerSearchPaths, + user: paths.userHeaderSearchPaths, + }); + } + if (units !== undefined && units.size > 0) { + return withCmakeLinks(lookup, fromFile, { header: [], user: [] }); + } + return withCmakeLinks(lookup, fromFile, { + header: lookup?.headerSearchPaths ?? [], + user: lookup?.userHeaderSearchPaths ?? [], + }); +} + +function withCmakeLinks( + lookup: CIncludeLookup | undefined, + fromFile: string, + lists: { readonly header: readonly string[]; readonly user: readonly string[] }, +): { readonly header: readonly string[]; readonly user: readonly string[] } { + const extra = cmakeLinksFor(lookup?.cmakeLinkHeaders, fromFile); + if (extra.length === 0) return lists; + return { header: [...new Set([...lists.header, ...extra])], user: lists.user }; +} + +function cmakeLinksFor( + links: ReadonlyMap | undefined, + fromFile: string, +): readonly string[] { + if (links === undefined || links.size === 0) return []; + const file = normalizeRepoPath(fromFile); + const exact = links.get(file); + if (exact !== undefined) return exact; + const dir = directoryOf(file); + const header: string[] = []; + for (const [source, paths] of links) { + if (directoryOf(source) !== dir) continue; + header.push(...paths); + } + return [...new Set(header)]; +} + +/** + * The database lists source files, not the headers they include. A header + * uses the `-I` / `-iquote` lists of a translation unit in the same directory. + * A file in another directory does not inherit that list. + */ +function pathsFromTranslationUnits( + units: ReadonlyMap, + fromFile: string, +): { readonly header: readonly string[]; readonly user: readonly string[] } | undefined { + const file = normalizeRepoPath(fromFile); + const dir = directoryOf(file); + const header: string[] = []; + const user: string[] = []; + let found = false; + for (const [unitFile, paths] of units) { + if (directoryOf(unitFile) !== dir) continue; + found = true; + header.push(...paths.headerSearchPaths); + user.push(...paths.userHeaderSearchPaths); + } + if (!found) return undefined; + return { header: [...new Set(header)], user: [...new Set(user)] }; +} + +function directoryOf(file: string): string { + const normalized = normalizeRepoPath(file); + const slash = normalized.lastIndexOf('/'); + return slash === -1 ? '' : normalized.slice(0, slash); +} + +function matchSearchPaths( + normalizedTarget: string, + roots: readonly string[], + allFilePaths: ReadonlySet, +): string | null { + if (roots.length === 0 || normalizedTarget.length === 0) return null; + for (const root of roots) { + const candidate = joinSearch(root, normalizedTarget); + if (candidate !== undefined && candidate.length > 0 && allFilePaths.has(candidate)) { + return candidate; + } + } + return null; +} + +function joinSearch(root: string, target: string): string | undefined { + if (root.length === 0 || root === '.') return collapseRepoPath(target); + return collapseRepoPath(`${root}/${target}`); +} diff --git a/gitnexus/src/core/ingestion/languages/c/interpret.ts b/gitnexus/src/core/ingestion/languages/c/interpret.ts index e5a15f2fe..4d1c3c9ef 100644 --- a/gitnexus/src/core/ingestion/languages/c/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/c/interpret.ts @@ -8,10 +8,11 @@ export function interpretCImport(captures: CaptureMatch): ParsedImport | null { const source = captures['@import.source']?.text; if (source === undefined) return null; - // System headers (e.g. ) are not resolved to local files - if (captures['@import.system'] !== undefined) return null; - - return { kind: 'wildcard', targetRaw: source }; + return { + kind: 'wildcard', + targetRaw: source, + isSystem: captures['@import.system'] !== undefined, + }; } /** diff --git a/gitnexus/src/core/ingestion/languages/c/resolution-config.ts b/gitnexus/src/core/ingestion/languages/c/resolution-config.ts new file mode 100644 index 000000000..0ef116d37 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/resolution-config.ts @@ -0,0 +1,955 @@ +/** + * Per-workspace C/C++ include config — the analog of Objective-C's header + * scan plus the project files clangd already reads. + * + * Loaded once per analyze pass and threaded into `resolveCImportTarget`. + * `#include ` joins the target onto `headerSearchPaths` and never + * suffix-matches a same-named file under `src/`. `#include "util.h"` may + * still look next to the importer and in the basename index. + * + * Config is read in every directory, not only the root, and a file takes + * the nearest one — how clangd finds `compile_commands.json` and + * `compile_flags.txt` in a monorepo. CMake `include_directories` and + * `target_include_directories` are read too. Make, Meson, and Bazel are not + * parsed; `compile_commands.json` is the compilation database they emit. + */ + +import { existsSync, readdirSync, readFileSync, type Dirent } from 'fs'; +import { isAbsolute, join, relative, resolve } from 'path'; +import { load as loadYaml } from 'js-yaml'; + +export const C_HEADER_EXTENSIONS: ReadonlySet = new Set(['.h']); +export const CPP_HEADER_EXTENSIONS: ReadonlySet = new Set([ + '.h', + '.hpp', + '.hxx', + '.hh', + '.cuh', +]); + +const IMPLICIT_INCLUDE_DIRECTORIES = new Set(['include', 'Headers', 'inc']); + +const SKIP_DIRECTORIES = new Set([ + 'node_modules', + '.git', + 'vendor', + 'dist', + 'build', + 'out', + 'target', + '_build', + '.next', + 'debug', + 'release', + 'bazel-out', + 'bazel-bin', + 'bazel-testlogs', + 'buck-out', +]); + +/** clangd's well-known locations. One file each — the build tree is not walked. */ +const COMPILE_COMMANDS_CANDIDATES = [ + 'compile_commands.json', + '.vscode/compile_commands.json', + 'build/compile_commands.json', + 'out/compile_commands.json', + 'debug/compile_commands.json', + 'release/compile_commands.json', +]; + +/** Include roots for one translation unit, or for files with no database entry. */ +export interface CTranslationUnitPaths { + /** `-I` / `-isystem` / `/I`. Implicit `include` / `Headers` / `inc` only when nothing is declared. */ + readonly headerSearchPaths: readonly string[]; + /** `-iquote`. Quoted includes only. */ + readonly userHeaderSearchPaths: readonly string[]; +} + +const EMPTY_TRANSLATION_UNITS: ReadonlyMap = new Map(); +const EMPTY_DIRECTORY_SCOPES: ReadonlyMap = new Map(); + +export interface CFamilyResolutionConfig { + /** In-repo headers the scan found. Build trees are not included. */ + readonly headers: ReadonlySet; + /** + * Roots for a file that has no `compile_commands.json` entry. + * Another translation unit's `-I` list is not copied here. + */ + readonly headerSearchPaths: readonly string[]; + /** `-iquote` roots for a file that has no compilation-database entry. */ + readonly userHeaderSearchPaths: readonly string[]; + /** + * Per source file, keyed by repo-relative path. Built once per pass. + * Lookup is a map read; the lists are not rebuilt per include. + */ + readonly translationUnits: ReadonlyMap; + /** + * Roots for a file with no database entry below the repo root, keyed by the + * directory whose config (or CMakeLists.txt) set them. A file takes its + * nearest ancestor's entry, else the top-level lists. + */ + readonly directoryScopes: ReadonlyMap; + /** + * Interface include roots a source file sees because its CMake target + * links another target. Keyed by repo-relative source path. + */ + readonly cmakeLinkHeaders?: ReadonlyMap; +} + +export function coerceCFamilyResolutionConfig(value: unknown): CFamilyResolutionConfig | undefined { + if (value == null) return undefined; + if (value instanceof Set) { + return { + headers: value as ReadonlySet, + headerSearchPaths: [], + userHeaderSearchPaths: [], + translationUnits: EMPTY_TRANSLATION_UNITS, + directoryScopes: EMPTY_DIRECTORY_SCOPES, + }; + } + if (typeof value !== 'object') return undefined; + const record = value as Partial; + if (!(record.headers instanceof Set)) return undefined; + return { + headers: record.headers, + headerSearchPaths: record.headerSearchPaths ?? [], + userHeaderSearchPaths: record.userHeaderSearchPaths ?? [], + translationUnits: + record.translationUnits instanceof Map ? record.translationUnits : EMPTY_TRANSLATION_UNITS, + directoryScopes: + record.directoryScopes instanceof Map ? record.directoryScopes : EMPTY_DIRECTORY_SCOPES, + cmakeLinkHeaders: record.cmakeLinkHeaders instanceof Map ? record.cmakeLinkHeaders : undefined, + }; +} + +/** + * The file set a resolver should hand to the include lookup. + * + * `augment` is the language's own per-pass memo (`augmentedFilePathsFor`). + * When there is nothing to union, `allFilePaths` is returned as the same + * object — a copy would be a new memo key on every include. + */ +export function cFamilyImportFiles( + allFilePaths: ReadonlySet, + resolutionConfig: unknown, + augment: (headers: ReadonlySet) => ReadonlySet, +): { readonly files: ReadonlySet; readonly config: CFamilyResolutionConfig | undefined } { + const config = coerceCFamilyResolutionConfig(resolutionConfig); + const headers = config?.headers; + const files = headers !== undefined && headers.size > 0 ? augment(headers) : allFilePaths; + return { files, config }; +} + +export function scanCFamilyHeaders( + repoPath: string, + headerExtensions: ReadonlySet, +): { + readonly headers: ReadonlySet; + readonly implicitRoots: readonly string[]; + readonly configDirectories: readonly string[]; +} { + const headers = new Set(); + const implicit: string[] = []; + const configDirectories: string[] = []; + walk(repoPath, repoPath, headerExtensions, headers, implicit, configDirectories); + return { headers, implicitRoots: sortByDepth(implicit), configDirectories }; +} + +/** What a directory hands down to its subdirectories, clangd- and CMake-style. */ +interface InheritedScope { + /** Nearest `compile_flags.txt` / `.ccls` / `includePath`. Empty under a database. */ + readonly flagHeader: readonly string[]; + readonly flagUser: readonly string[]; + /** `.clangd` `CompileFlags.Add` from every ancestor, outermost first. */ + readonly clangdHeader: readonly string[]; + readonly clangdUser: readonly string[]; + /** `include_directories` and PRIVATE / PUBLIC target roots from ancestor CMakeLists. */ + readonly cmake: readonly string[]; + /** + * A flag file, `.ccls`, or concrete `includePath` in this directory or an + * ancestor claimed the scope. An empty claim is not "no config": `finish` + * must not invent implicit `include/` roots for it. + */ + readonly flagsClaimed: boolean; + /** Absolute dirs for `${PROJECT_SOURCE_DIR}` and `${CMAKE_SOURCE_DIR}`. */ + readonly projectDir: string; + readonly cmakeRootDir: string | undefined; +} + +export function loadCFamilyResolutionConfig( + repoPath: string, + headerExtensions: ReadonlySet, +): CFamilyResolutionConfig { + const { headers, implicitRoots, configDirectories } = scanCFamilyHeaders( + repoPath, + headerExtensions, + ); + + const rootScope: InheritedScope = { + flagHeader: [], + flagUser: [], + clangdHeader: [], + clangdUser: [], + cmake: [], + projectDir: repoPath, + cmakeRootDir: undefined, + flagsClaimed: false, + }; + const scopes = new Map([['', rootScope]]); + const cmakeBits: CmakeBit[] = []; + const databases: { readonly depth: number; readonly units: ParsedUnits }[] = []; + const parsedDatabases = new Map(); + + // Pre-order: every parent is settled before its children. + for (const dir of configDirectories) { + const parent = nearestScope(scopes, dir) ?? rootScope; + const dirAbs = dir.length === 0 ? repoPath : join(repoPath, dir); + + const clangd = readClangd(dirAbs); + const vscode = readVscodeProperties(dirAbs, repoPath); + const database = findCompileCommands( + dirAbs, + repoPath, + clangd.databaseDir, + vscode.compileCommands, + ); + // Two directories can point at one database; parse it once, still honor it in both. + let parsed: ParsedUnits | undefined; + if (database !== undefined) { + const seen = parsedDatabases.has(database); + parsed = seen ? parsedDatabases.get(database) : parseCompileCommands(database, repoPath); + parsedDatabases.set(database, parsed); + if (!seen && parsed !== undefined) databases.push({ depth: depthOf(dir), units: parsed }); + } + + const clangdHeader = [...parent.clangdHeader]; + const clangdUser = [...parent.clangdUser]; + collectIncludeArgs(clangd.add, dirAbs, repoPath, clangdHeader, clangdUser); + + // clangd: the nearest directory with a database or flag file wins, and a + // database beats a flag file in the same directory. A file the database + // does not list gets no flags from here — not an ancestor's flag file. + let flagHeader = parent.flagHeader; + let flagUser = parent.flagUser; + let flagsClaimed = parent.flagsClaimed; + if (parsed !== undefined) { + flagHeader = []; + flagUser = []; + flagsClaimed = false; + } else { + const header: string[] = []; + const user: string[] = []; + let sawConcreteInclude = false; + for (const includePath of vscode.includePaths) { + if (isGlobIncludePath(includePath)) continue; + const rel = toRepoRelative(includePath, dirAbs, repoPath); + if (rel !== undefined) { + header.push(rel); + sawConcreteInclude = true; + } + } + const hasFlags = collectFlagFile( + join(dirAbs, 'compile_flags.txt'), + dirAbs, + repoPath, + header, + user, + ); + const hasCcls = collectFlagFile(join(dirAbs, '.ccls'), dirAbs, repoPath, header, user); + const hasFlagFile = hasFlags || hasCcls || sawConcreteInclude; + if (hasFlagFile) { + flagHeader = header; + flagUser = user; + flagsClaimed = true; + } + } + + const cmake = readCMakeLists(dirAbs, repoPath, parent); + cmakeBits.push(cmake); + + scopes.set(dir, { + flagHeader, + flagUser, + clangdHeader, + clangdUser, + cmake: cmake.scoped.length === 0 ? parent.cmake : [...parent.cmake, ...cmake.scoped], + projectDir: cmake.projectDir ?? parent.projectDir, + cmakeRootDir: parent.cmakeRootDir ?? cmake.cmakeRootDir, + flagsClaimed, + }); + } + + // Declared roots win. The implicit `include/` guess is only for a file no + // config speaks for; the compiler would not search those directories either. + const finish = (scope: InheritedScope): CTranslationUnitPaths => { + const header = uniquePaths([...scope.flagHeader, ...scope.clangdHeader, ...scope.cmake]); + return { + headerSearchPaths: header.length > 0 ? header : scope.flagsClaimed ? [] : implicitRoots, + userHeaderSearchPaths: uniquePaths([...scope.flagUser, ...scope.clangdUser]), + }; + }; + + const directoryScopes = new Map(); + for (const [dir, scope] of scopes) { + if (dir.length > 0) directoryScopes.set(dir, finish(scope)); + } + + // The nearest database lists a file first; a shallower one does not override it. + const translationUnits = new Map(); + databases.sort((left, right) => right.depth - left.depth); + for (const { units } of databases) { + for (const [file, paths] of units) { + if (translationUnits.has(file)) continue; + const scope = nearestScope(scopes, file) ?? rootScope; + translationUnits.set(file, { + headerSearchPaths: uniquePaths([...paths.header, ...scope.clangdHeader]), + userHeaderSearchPaths: uniquePaths([...paths.user, ...scope.clangdUser]), + }); + } + } + + const root = finish(scopes.get('') ?? rootScope); + return { + headers, + headerSearchPaths: root.headerSearchPaths, + userHeaderSearchPaths: root.userHeaderSearchPaths, + translationUnits, + directoryScopes, + cmakeLinkHeaders: cmakeUsageBySource(cmakeBits), + }; +} + +/** The scope of the deepest directory at or above `path`'s directory. */ +export function nearestScope(scopes: ReadonlyMap, path: string): T | undefined { + let dir = parentDirectory(normalizeRepoPath(path)); + for (;;) { + const scope = scopes.get(dir); + if (scope !== undefined) return scope; + if (dir.length === 0) return undefined; + dir = parentDirectory(dir); + } +} + +function parentDirectory(path: string): string { + const slash = path.lastIndexOf('/'); + return slash === -1 ? '' : path.slice(0, slash); +} + +function depthOf(dir: string): number { + return dir.length === 0 ? 0 : dir.split('/').length; +} + +/** Shallower include roots first, then lexicographic. `deps/include` must not beat `include`. */ +function sortByDepth(paths: readonly string[]): string[] { + return [...paths].sort((left, right) => { + const leftDepth = depthOf(left); + const rightDepth = depthOf(right); + if (leftDepth !== rightDepth) return leftDepth - rightDepth; + if (left < right) return -1; + if (left > right) return 1; + return 0; + }); +} + +/** Entries that make a directory worth reading config from. Build dirs may hold a database. */ +const CONFIG_FILE_NAMES = new Set([ + 'compile_commands.json', + 'compile_flags.txt', + '.ccls', + '.clangd', + 'CMakeLists.txt', +]); +const DATABASE_DIRECTORY_NAMES = new Set(['.vscode', 'build', 'out', 'debug', 'release']); + +function walk( + dir: string, + root: string, + headerExtensions: ReadonlySet, + headers: Set, + implicit: string[], + configDirectories: string[], +): void { + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' }); + } catch { + return; + } + const relativeHere = normalizeRepoPath(relative(root, dir)); + if ( + entries.some((entry) => + entry.isDirectory() + ? DATABASE_DIRECTORY_NAMES.has(entry.name) + : CONFIG_FILE_NAMES.has(entry.name), + ) + ) { + configDirectories.push(relativeHere); + } + for (const entry of entries) { + const name = entry.name; + const full = join(dir, name); + if (entry.isDirectory()) { + if (shouldSkipDirectory(name)) continue; + const relativeDir = normalizeRepoPath(relative(root, full)); + if (IMPLICIT_INCLUDE_DIRECTORIES.has(name)) implicit.push(relativeDir); + walk(full, root, headerExtensions, headers, implicit, configDirectories); + } else if (entry.isFile()) { + const dot = name.lastIndexOf('.'); + const ext = dot === -1 ? '' : name.slice(dot); + if (headerExtensions.has(ext)) { + headers.add(normalizeRepoPath(relative(root, full))); + } + } + } +} + +function shouldSkipDirectory(name: string): boolean { + return SKIP_DIRECTORIES.has(name) || name.startsWith('cmake-build'); +} + +function findCompileCommands( + dirAbs: string, + repoPath: string, + clangdDatabaseDir: string | undefined, + vscodeCompileCommands: string | undefined, +): string | undefined { + if (clangdDatabaseDir !== undefined) { + const pointed = compileCommandsFile(dirAbs, repoPath, clangdDatabaseDir); + if (pointed !== undefined) return pointed; + } + for (const rel of COMPILE_COMMANDS_CANDIDATES) { + const full = join(dirAbs, rel); + if (existsSync(full)) return full; + } + if (vscodeCompileCommands !== undefined && existsSync(vscodeCompileCommands)) { + return vscodeCompileCommands; + } + return undefined; +} + +function compileCommandsFile( + dirAbs: string, + repoPath: string, + databaseDir: string, +): string | undefined { + const rel = toRepoRelative(databaseDir, dirAbs, repoPath); + if (rel === undefined) return undefined; + const full = join(repoPath, rel, 'compile_commands.json'); + return existsSync(full) ? full : undefined; +} + +interface ClangdFlags { + readonly add: readonly string[]; + readonly databaseDir?: string; +} + +function readClangd(dirAbs: string): ClangdFlags { + const text = readText(join(dirAbs, '.clangd')); + if (text.length === 0) return { add: [] }; + let doc: unknown; + try { + doc = loadYaml(text); + } catch { + return { add: [] }; + } + if (doc === null || typeof doc !== 'object') return { add: [] }; + const flags = recordCompileFlags(doc); + const nested = flags?.CompilationDatabase; + const topLevel = (doc as { CompilationDatabase?: unknown }).CompilationDatabase; + const databaseDir = + typeof nested === 'string' ? nested : typeof topLevel === 'string' ? topLevel : undefined; + return { add: clangdAddFlags(flags?.Add), databaseDir }; +} + +function clangdAddFlags(add: unknown): string[] { + if (typeof add === 'string') return splitCommand(add); + if (!Array.isArray(add)) return []; + return add.filter((flag): flag is string => typeof flag === 'string'); +} + +function recordCompileFlags( + doc: object, +): { Add?: unknown; CompilationDatabase?: unknown } | undefined { + const flags = (doc as { CompileFlags?: unknown }).CompileFlags; + if (flags === null || typeof flags !== 'object') return undefined; + return flags as { Add?: unknown; CompilationDatabase?: unknown }; +} + +/** `${workspaceFolder}/**` is a recursive glob, not a search root. */ +function isGlobIncludePath(raw: string): boolean { + const substituted = raw.replaceAll('${workspaceFolder}', '').replaceAll('${workspaceRoot}', ''); + return substituted.includes('*'); +} + +interface VscodeProperties { + readonly includePaths: readonly string[]; + readonly compileCommands?: string; +} + +function readVscodeProperties(dirAbs: string, repoPath: string): VscodeProperties { + const text = readText(join(dirAbs, '.vscode', 'c_cpp_properties.json')); + if (text.length === 0) return { includePaths: [] }; + let doc: unknown; + try { + doc = JSON.parse(text); + } catch { + return { includePaths: [] }; + } + if (doc === null || typeof doc !== 'object') return { includePaths: [] }; + const configurations = (doc as { configurations?: unknown }).configurations; + if (!Array.isArray(configurations)) return { includePaths: [] }; + + const includePaths: string[] = []; + let compileCommands: string | undefined; + for (const configuration of configurations) { + if (configuration === null || typeof configuration !== 'object') continue; + const record = configuration as { includePath?: unknown; compileCommands?: unknown }; + if (Array.isArray(record.includePath)) { + for (const entry of record.includePath) { + if (typeof entry === 'string') includePaths.push(entry); + } + } + if (compileCommands === undefined && typeof record.compileCommands === 'string') { + const resolved = resolvePointedPath(record.compileCommands, dirAbs, repoPath); + if (resolved !== undefined) compileCommands = resolved; + } + } + return { includePaths, compileCommands }; +} + +function resolvePointedPath(raw: string, dirAbs: string, repoPath: string): string | undefined { + const rel = toRepoRelative(raw, dirAbs, repoPath); + if (rel === undefined) return undefined; + return rel.length === 0 ? repoPath : join(repoPath, rel); +} + +const CMAKE_INCLUDE_COMMAND = + /(?:^|[^\w])(include_directories|target_include_directories)\s*\(([^)]*)\)/gi; +const CMAKE_TARGET_COMMAND = /(?:^|[^\w])(add_library|add_executable)\s*\(([^)]*)\)/gi; +const CMAKE_LINK_COMMAND = /(?:^|[^\w])target_link_libraries\s*\(([^)]*)\)/gi; +const CMAKE_PROJECT_COMMAND = /(?:^|[^\w])project\s*\(/i; +const CMAKE_ORDER_KEYWORDS = new Set(['SYSTEM', 'BEFORE', 'AFTER']); + +/** + * Drop CMake line comments and bracket comments (`#[[ ... ]]`) without + * eating `#` inside a quoted argument. + */ +function stripCMakeComments(text: string): string { + let out = ''; + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (ch === '"') { + const start = i; + i += 1; + while (i < text.length && text[i] !== '"') { + if (text[i] === '\\') i += 1; + i += 1; + } + if (i < text.length) i += 1; + out += text.slice(start, i); + continue; + } + if (ch === '#') { + const bracket = text.slice(i + 1).match(/^\[(=*)\[/); + if (bracket !== null) { + const close = `]${bracket[1]}]`; + const bodyStart = i + 1 + bracket[0].length; + const end = text.indexOf(close, bodyStart); + i = end === -1 ? text.length : end + close.length; + continue; + } + const nl = text.indexOf('\n', i); + i = nl === -1 ? text.length : nl; + continue; + } + out += ch; + i += 1; + } + return out; +} + +interface CmakeBit { + readonly scoped: readonly string[]; + readonly projectDir: string | undefined; + readonly cmakeRootDir: string | undefined; + readonly targets: readonly { readonly name: string; readonly sources: readonly string[] }[]; + readonly interfaceIncludes: readonly { readonly target: string; readonly path: string }[]; + readonly links: readonly { + readonly target: string; + readonly dep: string; + readonly propagate: boolean; + }[]; +} + +const CMAKE_TARGET_KEYWORDS = new Set([ + 'STATIC', + 'SHARED', + 'MODULE', + 'OBJECT', + 'INTERFACE', + 'ALIAS', + 'IMPORTED', + 'GLOBAL', + 'EXCLUDE_FROM_ALL', +]); +const CMAKE_LINK_SKIP = new Set(['debug', 'optimized', 'general']); + +/** + * Include roots one CMakeLists.txt declares. + * + * `include_directories` and PRIVATE/PUBLIC target roots reach this directory's + * subtree (PUBLIC is also an interface root). INTERFACE roots and linked + * targets' interface roots reach only the dependents recorded by + * `target_link_libraries`. A path left with `${VAR}` or a generator expression + * is dropped, not guessed. + */ +function readCMakeLists(dirAbs: string, repoPath: string, parent: InheritedScope): CmakeBit { + const text = readText(join(dirAbs, 'CMakeLists.txt')); + if (text.length === 0) { + return { + scoped: [], + projectDir: undefined, + cmakeRootDir: undefined, + targets: [], + interfaceIncludes: [], + links: [], + }; + } + const source = stripCMakeComments(text); + const projectDir = CMAKE_PROJECT_COMMAND.test(source) ? dirAbs : undefined; + const variables = new Map([ + ['CMAKE_CURRENT_SOURCE_DIR', dirAbs], + ['CMAKE_CURRENT_LIST_DIR', dirAbs], + ['PROJECT_SOURCE_DIR', projectDir ?? parent.projectDir], + ['CMAKE_SOURCE_DIR', parent.cmakeRootDir ?? dirAbs], + ]); + + const scoped: string[] = []; + const targets: { name: string; sources: string[] }[] = []; + const interfaceIncludes: { target: string; path: string }[] = []; + const links: { target: string; dep: string; propagate: boolean }[] = []; + + for (const match of source.matchAll(CMAKE_TARGET_COMMAND)) { + const parsed = cmakeTargetArgs(match[2] ?? '', dirAbs, repoPath); + if (parsed.name !== undefined) targets.push(parsed); + } + for (const match of source.matchAll(CMAKE_LINK_COMMAND)) { + links.push(...cmakeLinkArgs(match[1] ?? '')); + } + + for (const match of source.matchAll(CMAKE_INCLUDE_COMMAND)) { + const isTarget = match[1]?.toLowerCase() === 'target_include_directories'; + const args = cmakeArgTokens(match[2] ?? ''); + const targetName = isTarget ? args[0] : undefined; + let visibility = 'PRIVATE'; + for (const arg of isTarget ? args.slice(1) : args) { + if (CMAKE_ORDER_KEYWORDS.has(arg)) continue; + if (arg === 'PUBLIC' || arg === 'PRIVATE' || arg === 'INTERFACE') { + visibility = arg; + continue; + } + const expanded = expandCMakePath(arg, variables); + if (expanded === undefined) continue; + const rel = toRepoRelative(expanded, dirAbs, repoPath); + if (rel === undefined) continue; + if (visibility !== 'INTERFACE') scoped.push(rel); + if (isTarget && targetName !== undefined && visibility !== 'PRIVATE') { + interfaceIncludes.push({ target: targetName, path: rel }); + } + } + } + return { scoped, projectDir, cmakeRootDir: dirAbs, targets, interfaceIncludes, links }; +} + +function cmakeArgTokens(body: string): string[] { + return [...body.matchAll(/"([^"]*)"|(\S+)/g)].map((arg) => arg[1] ?? arg[2] ?? ''); +} + +function cmakeTargetArgs( + body: string, + dirAbs: string, + repoPath: string, +): { name: string | undefined; sources: string[] } { + let name: string | undefined; + const sources: string[] = []; + for (const arg of cmakeArgTokens(body)) { + if (CMAKE_TARGET_KEYWORDS.has(arg)) continue; + if (name === undefined) { + name = arg; + continue; + } + if (!arg.includes('/') && !arg.includes('.')) continue; + const rel = toRepoRelative(arg, dirAbs, repoPath); + if (rel !== undefined) sources.push(rel); + } + return { name, sources }; +} + +function cmakeLinkArgs(body: string): { target: string; dep: string; propagate: boolean }[] { + const args = cmakeArgTokens(body); + const target = args[0]; + if (target === undefined) return []; + let propagate = true; + const links: { target: string; dep: string; propagate: boolean }[] = []; + for (const arg of args.slice(1)) { + if (arg === 'PRIVATE' || arg === 'PUBLIC' || arg === 'INTERFACE') { + propagate = arg !== 'PRIVATE'; + continue; + } + if (CMAKE_LINK_SKIP.has(arg) || arg.startsWith('$') || arg.startsWith('-')) continue; + links.push({ target, dep: arg, propagate }); + } + return links; +} + +/** Interface include roots each source file sees through `target_link_libraries`. */ +function cmakeUsageBySource(bits: readonly CmakeBit[]): ReadonlyMap { + const sources = new Map(); + const interfaceIncludes = new Map(); + const links = new Map(); + for (const bit of bits) { + for (const target of bit.targets) { + const list = sources.get(target.name) ?? []; + list.push(...target.sources); + sources.set(target.name, list); + } + for (const include of bit.interfaceIncludes) { + const list = interfaceIncludes.get(include.target) ?? []; + list.push(include.path); + interfaceIncludes.set(include.target, list); + } + for (const link of bit.links) { + const list = links.get(link.target) ?? []; + list.push({ dep: link.dep, propagate: link.propagate }); + links.set(link.target, list); + } + } + + const usageOf = (name: string): string[] => { + const out: string[] = []; + const seen = new Set(); + const addInterface = (dep: string): void => { + if (seen.has(dep)) return; + seen.add(dep); + out.push(...(interfaceIncludes.get(dep) ?? [])); + for (const next of links.get(dep) ?? []) { + if (next.propagate) addInterface(next.dep); + } + }; + for (const link of links.get(name) ?? []) addInterface(link.dep); + return [...new Set(out)]; + }; + + const bySource = new Map(); + for (const [name, files] of sources) { + const unique = [...new Set(usageOf(name))]; + if (unique.length === 0) continue; + for (const file of files) bySource.set(file, unique); + } + return bySource; +} + +function expandCMakePath( + token: string, + variables: ReadonlyMap, +): string | undefined { + const buildInterface = /^\$$/.exec(token); + const path = buildInterface?.[1] ?? token; + if (path.includes('$<')) return undefined; + return path.replace(/\$\{(\w+)\}/g, (whole, name: string) => variables.get(name) ?? whole); +} + +type ParsedUnits = Map; + +/** + * Per-file include roots. `undefined` means the file exists but is not a + * usable compilation database, so callers must fall through to flag files. + */ +function parseCompileCommands(filePath: string, repoPath: string): ParsedUnits | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(readText(filePath)); + } catch { + return undefined; + } + if (!Array.isArray(parsed)) return undefined; + const units: ParsedUnits = new Map(); + for (const entry of parsed) { + if (entry === null || typeof entry !== 'object') continue; + const record = entry as { + directory?: unknown; + command?: unknown; + arguments?: unknown; + file?: unknown; + }; + if (typeof record.file !== 'string' || record.file.length === 0) continue; + const args = Array.isArray(record.arguments) + ? record.arguments.filter((arg): arg is string => typeof arg === 'string') + : typeof record.command === 'string' + ? splitCommand(record.command) + : []; + const directory = typeof record.directory === 'string' ? record.directory : repoPath; + const base = isAbsolute(directory) ? directory : join(repoPath, directory); + const fileRel = toRepoRelative(record.file, base, repoPath); + // First entry for a file wins. A later command must not merge its -I list in. + if (fileRel === undefined || units.has(fileRel)) continue; + const header: string[] = []; + const user: string[] = []; + collectIncludeArgs(args, base, repoPath, header, user); + units.set(fileRel, { header, user }); + } + return units; +} + +/** Reads one flag file. Returns whether it exists, so an empty one still claims its directory. */ +function collectFlagFile( + filePath: string, + baseDir: string, + repoPath: string, + header: string[], + user: string[], +): boolean { + if (!existsSync(filePath)) return false; + const text = readText(filePath); + const args: string[] = []; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line.length === 0 || line.startsWith('#') || line.startsWith('%')) continue; + args.push(line); + } + collectIncludeArgs(args, baseDir, repoPath, header, user); + return true; +} + +function collectIncludeArgs( + args: readonly string[], + baseDir: string, + repoPath: string, + header: string[], + user: string[], +): void { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === undefined) continue; + const eaten = takeIncludeFlag(arg, args[i + 1]); + if (eaten === undefined) continue; + if (eaten.consumedNext) i++; + const rel = toRepoRelative(eaten.path, baseDir, repoPath); + if (rel === undefined) continue; + (eaten.quotedOnly ? user : header).push(rel); + } +} + +function takeIncludeFlag( + arg: string, + next: string | undefined, +): + | { readonly path: string; readonly quotedOnly: boolean; readonly consumedNext: boolean } + | undefined { + if (arg === '-I' || arg === '-isystem' || arg === '/I' || arg === '-iquote') { + if (next === undefined || next.startsWith('-')) return undefined; + return { path: next, quotedOnly: arg === '-iquote', consumedNext: true }; + } + if (arg.startsWith('-I')) { + return { path: arg.slice(2), quotedOnly: false, consumedNext: false }; + } + if (arg.startsWith('/I') && arg.length > 2) { + return { path: arg.slice(2), quotedOnly: false, consumedNext: false }; + } + if (arg.startsWith('-isystem') && arg.length > '-isystem'.length) { + return { path: arg.slice('-isystem'.length), quotedOnly: false, consumedNext: false }; + } + if (arg.startsWith('-iquote') && arg.length > '-iquote'.length) { + return { path: arg.slice('-iquote'.length), quotedOnly: true, consumedNext: false }; + } + return undefined; +} + +/** Split a compiler command the way `arguments` already is. Quotes are kept out of the tokens. */ +function splitCommand(command: string): string[] { + const args: string[] = []; + let current = ''; + let quote: '"' | "'" | null = null; + for (let i = 0; i < command.length; i++) { + const ch = command[i]; + if (quote !== null) { + if (ch === quote) quote = null; + else current += ch; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '\\' && i + 1 < command.length) { + const next = command[i + 1]; + // A Windows path separator is not a shell escape. Only drop the + // backslash when it quotes the next character. + if (next === '"' || next === "'" || next === '\\' || next === ' ' || next === '\t') { + current += next; + i++; + continue; + } + current += ch; + continue; + } + if (ch === ' ' || ch === '\t') { + if (current.length > 0) { + args.push(current); + current = ''; + } + continue; + } + current += ch ?? ''; + } + if (current.length > 0) args.push(current); + return args; +} + +function toRepoRelative(rawPath: string, baseDir: string, repoPath: string): string | undefined { + let raw = normalizeRepoPath(rawPath.trim()); + if (raw.length === 0) return undefined; + raw = raw.replaceAll('${workspaceFolder}', '.').replaceAll('${workspaceRoot}', '.'); + if (raw.includes('${') || raw.includes('$(')) return undefined; + raw = raw.replace(/\/\*\*$/, '').replace(/\/\*$/, ''); + if (raw.length === 0) raw = '.'; + + const absolute = isAbsolute(raw) || /^[A-Za-z]:/.test(raw) ? raw : join(baseDir, raw); + const rel = normalizeRepoPath(relative(repoPath, resolve(absolute))); + // `..headers` is a directory inside the repo. Only `..` and `../…` leave it. + if (rel === '..' || rel.startsWith('../') || isAbsolute(rel)) return undefined; + return collapseRepoPath(rel); +} + +function uniquePaths(paths: readonly string[]): string[] { + return [...new Set(paths)]; +} + +function readText(filePath: string): string { + try { + return readFileSync(filePath, 'utf8'); + } catch { + return ''; + } +} + +export function normalizeRepoPath(value: string): string { + return value.replaceAll('\\', '/').replace(/^\.\//, ''); +} + +/** Collapse `.` / `..` in a repo-relative path. `..` past the root is a miss, not a clipped path. */ +export function collapseRepoPath(value: string): string | undefined { + const parts = normalizeRepoPath(value).split('/'); + const out: string[] = []; + for (const part of parts) { + if (part === '' || part === '.') continue; + if (part === '..') { + if (out.length === 0) return undefined; + out.pop(); + continue; + } + out.push(part); + } + return out.join('/'); +} diff --git a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts index c3f9fb36f..60fbb085d 100644 --- a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts @@ -4,8 +4,13 @@ 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 { cArityCompatibility, cMergeBindings } from './index.js'; +import { cIncludeLookupFromConfig, resolveCImportTarget } from './import-target.js'; +import { + C_HEADER_EXTENSIONS, + cFamilyImportFiles, + loadCFamilyResolutionConfig, +} from './resolution-config.js'; import { expandCWildcardNames, isStaticName, clearStaticNames } from './static-linkage.js'; import { applyCStaticLinkageSideChannel } from './capture-side-channel.js'; import { perFileSet } from '../../import-resolvers/per-file-set.js'; @@ -61,7 +66,7 @@ export const cScopeResolver: ScopeResolver = { // Clear stale static-linkage data from any previous invocation to // prevent cross-repo contamination in server-mode scenarios. clearStaticNames(); - return scanHeaderFiles(repoPath); + return loadCFamilyResolutionConfig(repoPath, C_HEADER_EXTENSIONS); }, // Worker-boundary restore (see `ScopeResolver.applyCaptureSideChannel`). @@ -81,19 +86,25 @@ export const cScopeResolver: ScopeResolver = { // this process. Runs BEFORE `populateOwners`. applyCaptureSideChannel: applyCStaticLinkageSideChannel, - resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => { + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig, context) => { // 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) { - return resolveCImportTarget( - targetRaw, - fromFile, - augmentedFilePathsFor(allFilePaths)(headerPaths), - ); - } - return resolveCImportTarget(targetRaw, fromFile, allFilePaths); + // targets .h files classified as C++ in language detection. A raw header + // Set (the import-target bench) is the same shape with empty search paths. + // The augmented set is the memo key — pass it through, never copy it. + const { files, config } = cFamilyImportFiles( + allFilePaths, + resolutionConfig, + augmentedFilePathsFor(allFilePaths), + ); + const parsed = context?.parsedImport; + const isSystem = parsed?.kind === 'wildcard' && parsed.isSystem === true; + return resolveCImportTarget( + targetRaw, + fromFile, + files, + cIncludeLookupFromConfig(config, isSystem), + ); }, expandsWildcardTo: (targetModuleScope, parsedFiles) => diff --git a/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts b/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts index 799c70820..6c3ec445c 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts @@ -1,53 +1,9 @@ -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', '.hpp', '.hxx', '.hh', '.cuh']); +import { CPP_HEADER_EXTENSIONS, scanCFamilyHeaders } from '../c/resolution-config.js'; /** - * Walk `repoPath` recursively and return relative paths of all C++ header files. - * Used by `loadResolutionConfig` so the C++ resolver can resolve `#include` - * targets that live in header files. - * - * Scans for: .h, .hpp, .hxx, .hh, .cuh + * Walk `repoPath` and return relative paths of C++ headers + * (`.h`, `.hpp`, `.hxx`, `.hh`, `.cuh`). */ export function scanCppHeaderFiles(repoPath: string): ReadonlySet { - const headers = new Set(); - walk(repoPath, repoPath, headers); - return headers; -} - -function walk(dir: string, root: string, out: Set): 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()) { - 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)) { - out.add(relative(root, full).replace(/\\/g, '/')); - } - } - } + return scanCFamilyHeaders(repoPath, CPP_HEADER_EXTENSIONS).headers; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/import-target.ts b/gitnexus/src/core/ingestion/languages/cpp/import-target.ts index 26e317c6e..b6c46b062 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/import-target.ts @@ -1,18 +1,22 @@ -import { resolveCImportTarget } from '../c/import-target.js'; +import { + createCIncludeSuffixIndex, + resolveCFamilyImport, + type CIncludeLookup, +} from '../c/import-target.js'; /** - * Resolve a C++ #include path to a file in the workspace. - * C++ #include path resolution is identical to C: - * 1. Same-directory sibling (relative lookup) - * 2. Exact match - * 3. Suffix match with depth + lexicographic tiebreak - * - * Re-exports the C implementation since the #include semantics are shared. + * C++ `#include` resolution is the C lookup. This module's own suffix + * index is the one thing that is not shared: the memo is keyed on the + * file set, and one map for both languages would hand each the other's + * index when a test passes the same set to both. */ +const cppSuffixIndex = createCIncludeSuffixIndex(); + export function resolveCppImportTarget( targetRaw: string, fromFile: string, allFilePaths: ReadonlySet, + lookup?: CIncludeLookup, ): string | null { - return resolveCImportTarget(targetRaw, fromFile, allFilePaths); + return resolveCFamilyImport(targetRaw, fromFile, allFilePaths, lookup, cppSuffixIndex); } diff --git a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts index e5a3635ca..635b09264 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts @@ -8,15 +8,14 @@ import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'git * 2. using namespace X; → wildcard import (all symbols from namespace X) * 3. using X::name; → named import (single symbol from namespace X) * - * System headers (#include <...>) are not resolved to local files. + * Angle `#include <...>` sets `isSystem`. The resolver searches header + * paths for that form and does not use the basename index. */ export function interpretCppImport(captures: CaptureMatch): ParsedImport | null { const source = captures['@import.source']?.text; if (source === undefined) return null; - // System headers are not resolved to local files - if (captures['@import.system'] !== undefined) return null; - + const isSystem = captures['@import.system'] !== undefined; const kind = captures['@import.kind']?.text; if (kind === 'named') { @@ -27,7 +26,7 @@ export function interpretCppImport(captures: CaptureMatch): ParsedImport | null } // #include or using namespace — wildcard import - return { kind: 'wildcard', targetRaw: source }; + return { kind: 'wildcard', targetRaw: source, isSystem }; } /** diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 9421975ea..e88581063 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -15,7 +15,12 @@ import { cppArityCompatibility } from './arity.js'; import { CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES, cppConversionRank } from './conversion-rank.js'; import { cppMergeBindings } from './merge-bindings.js'; import { resolveCppImportTarget } from './import-target.js'; -import { scanCppHeaderFiles } from './header-scan.js'; +import { cIncludeLookupFromConfig } from '../c/import-target.js'; +import { + CPP_HEADER_EXTENSIONS, + cFamilyImportFiles, + loadCFamilyResolutionConfig, +} from '../c/resolution-config.js'; import { expandCppWildcardNames, isFileLocal, @@ -110,22 +115,26 @@ export const cppScopeResolver: ScopeResolver = { clearCppInlineNamespaces(); clearCppUserDefinedConversions(); clearCppMemberLookupState(); - return scanCppHeaderFiles(repoPath); + return loadCFamilyResolutionConfig(repoPath, CPP_HEADER_EXTENSIONS); }, - resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => { - // Augment allFilePaths with header files discovered via loadResolutionConfig. - // C++ .h/.hpp/.hxx/.hh files may be classified differently by language - // detection but are importable from .cpp files via #include. - const headerPaths = resolutionConfig as ReadonlySet | undefined; - if (headerPaths !== undefined && headerPaths.size > 0) { - return resolveCppImportTarget( - targetRaw, - fromFile, - augmentedFilePathsFor(allFilePaths)(headerPaths), - ); - } - return resolveCppImportTarget(targetRaw, fromFile, allFilePaths); + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig, context) => { + // Same adapter as C, with this file's own augmented-set memo. C++ + // `#include` then goes through `resolveCppImportTarget`, whose suffix + // index is also private to C++. + const { files, config } = cFamilyImportFiles( + allFilePaths, + resolutionConfig, + augmentedFilePathsFor(allFilePaths), + ); + const parsed = context?.parsedImport; + const isSystem = parsed?.kind === 'wildcard' && parsed.isSystem === true; + return resolveCppImportTarget( + targetRaw, + fromFile, + files, + cIncludeLookupFromConfig(config, isSystem), + ); }, expandsWildcardTo: (targetModuleScope, parsedFiles) => diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 6c75bf39d..c3e8fc1b5 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -787,7 +787,10 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // Ruby multi-statement `if` one opaque source. // v113 (#3371): `.ipynb` code cells are extracted to Python before parse. // Warm caches keyed on raw JSON would replay empty/failed Python parses. -const SCHEMA_BUMP = 113; +// v114 (#2965): C and C++ angle includes are real wildcard imports with +// `isSystem`. Warm shards stored those captures as absent, so incremental +// analyze never asked the resolver to search include paths. 113 is #3371. +const SCHEMA_BUMP = 114; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index c08498613..d949208d1 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -291,8 +291,11 @@ describe('PARSE_CACHE_VERSION', () => { // Moved 104 -> 112 for #3354: callable-value flow follows `??`/`||`/`?:` // branches. 105-111 are claimed by open PR #3326. // Moved 112 -> 113 for #3371: notebook code-cell extraction before Python parse. - it('pins SCHEMA_BUMP to 113 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(113); + // Moved 113 -> 114 for #2965: C/C++ angle includes survive interpret as + // `isSystem` wildcards. Warm shards omitted them, so include-path lookup + // never ran until a full reparse. 113 stays taken by #3371. + it('pins SCHEMA_BUMP to 114 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(114); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly @@ -301,7 +304,7 @@ describe('PARSE_CACHE_VERSION', () => { for (const taken of [ 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, - 104, 105, 106, 107, 108, 109, 110, 111, 112, + 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } 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 549e651bb..ba9580341 100644 --- a/gitnexus/test/unit/scope-resolution/c/c-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/c/c-imports.test.ts @@ -57,16 +57,16 @@ describe('C import interpretation (interpretCImport)', () => { '@import.kind': capt('@import.kind', 'wildcard'), '@import.source': capt('@import.source', 'header.h'), }); - expect(result).toEqual({ kind: 'wildcard', targetRaw: 'header.h' }); + expect(result).toEqual({ kind: 'wildcard', targetRaw: 'header.h', isSystem: false }); }); - it('returns null for system headers', () => { + it('keeps the angle-bracket flag 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(); + expect(result).toEqual({ kind: 'wildcard', targetRaw: 'stdio.h', isSystem: true }); }); it('returns null when @import.source is missing', () => { @@ -154,6 +154,30 @@ describe('C import target resolution (resolveCImportTarget)', () => { expect(result).toBe('lib/missing.h'); }); + it('resolves an angle include only on a header search path', () => { + const files = new Set(['src/stdio.h', 'include/util.h', 'src/main.c']); + expect( + resolveCImportTarget('util.h', 'src/main.c', files, { + isSystem: true, + headerSearchPaths: ['include'], + }), + ).toBe('include/util.h'); + expect( + resolveCImportTarget('stdio.h', 'src/main.c', files, { + isSystem: true, + headerSearchPaths: ['include'], + }), + ).toBeNull(); + }); + + it('keeps quoted suffix matching when no search path is declared', () => { + const files = new Set(['src/stdio.h', 'include/util.h']); + expect(resolveCImportTarget('util.h', 'src/main.c', files, { isSystem: false })).toBe( + 'include/util.h', + ); + expect(resolveCImportTarget('stdio.h', 'src/main.c', files, { isSystem: true })).toBeNull(); + }); + it('same-directory sibling with nested target path', () => { // src/foo.c includes "sub/bar.h" — src/sub/bar.h should win const result = resolveCImportTarget( diff --git a/gitnexus/test/unit/scope-resolution/c/c-workspace.test.ts b/gitnexus/test/unit/scope-resolution/c/c-workspace.test.ts new file mode 100644 index 000000000..732f01b46 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/c/c-workspace.test.ts @@ -0,0 +1,670 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { cScopeResolver } from '../../../../src/core/ingestion/languages/c/scope-resolver.js'; +import { scanCppHeaderFiles } from '../../../../src/core/ingestion/languages/cpp/header-scan.js'; +import { cppScopeResolver } from '../../../../src/core/ingestion/languages/cpp/scope-resolver.js'; +import { + C_HEADER_EXTENSIONS, + CPP_HEADER_EXTENSIONS, + loadCFamilyResolutionConfig, +} from '../../../../src/core/ingestion/languages/c/resolution-config.js'; +import type { ImportResolutionContext } from '../../../../src/core/ingestion/scope-resolution/contract/scope-resolver.js'; + +const TMP = join(__dirname, '__c_workspace_tmp__'); + +function touch(rel: string, contents = ''): void { + const full = join(TMP, rel); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, contents); +} + +function angle(targetRaw: string): ImportResolutionContext { + return { + parsedFiles: [], + parsedImport: { kind: 'wildcard', targetRaw, isSystem: true }, + }; +} + +function quoted(targetRaw: string): ImportResolutionContext { + return { + parsedFiles: [], + parsedImport: { kind: 'wildcard', targetRaw, isSystem: false }, + }; +} + +beforeEach(() => { + mkdirSync(TMP, { recursive: true }); +}); + +afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); +}); + +describe('C/C++ workspace scan', () => { + it('finds headers, skips build output, and records implicit include roots', () => { + touch('include/util.h'); + touch('Headers/Widget.h'); + touch('inc/local.h'); + touch('src/stdio.h'); + touch('build/generated.h'); + touch('debug/generated.h'); + touch('release/generated.h'); + touch('cmake-build-debug/generated.h'); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(scanned.headers).toContain('include/util.h'); + expect(scanned.headers).toContain('src/stdio.h'); + expect(scanned.headers).not.toContain('build/generated.h'); + expect(scanned.headers).not.toContain('debug/generated.h'); + expect(scanned.headers).not.toContain('release/generated.h'); + expect(scanned.headers).not.toContain('cmake-build-debug/generated.h'); + expect(scanned.headerSearchPaths).toEqual( + expect.arrayContaining(['include', 'Headers', 'inc']), + ); + expect(scanned.headerSearchPaths).not.toContain('src'); + }); + + it('scans C++ header extensions and still skips the build tree', () => { + touch('include/util.hpp'); + touch('src/widget.hh'); + touch('build/generated.hpp'); + const scanned = loadCFamilyResolutionConfig(TMP, CPP_HEADER_EXTENSIONS); + expect(scanCppHeaderFiles(TMP)).toEqual(scanned.headers); + expect(scanned.headers).toContain('include/util.hpp'); + expect(scanned.headers).toContain('src/widget.hh'); + expect(scanned.headers).not.toContain('build/generated.hpp'); + }); + + it('reads compile_commands.json and drops absolute system roots', () => { + touch('include/util.h'); + touch('Headers/Widget.h'); + touch('private/local.h'); + touch('src/stdio.h'); + touch( + 'compile_commands.json', + JSON.stringify([ + { + directory: join(TMP, 'src'), + file: 'main.c', + command: 'gcc -I../include -isystem /usr/include -iquote ../private -c main.c', + }, + { + directory: TMP, + file: 'a.c', + arguments: ['cl', '/I', 'msvc', '/I/usr/include'], + }, + ]), + ); + touch('compile_flags.txt', '-Iignored\n'); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + const main = scanned.translationUnits.get('src/main.c'); + const other = scanned.translationUnits.get('a.c'); + expect(main?.headerSearchPaths).toContain('include'); + expect(main?.userHeaderSearchPaths).toEqual(['private']); + expect(main?.headerSearchPaths).not.toContain('msvc'); + expect(other?.headerSearchPaths).toContain('msvc'); + expect(other?.headerSearchPaths).not.toContain('private'); + expect(scanned.headerSearchPaths).toContain('Headers'); + expect(scanned.headerSearchPaths).not.toContain('msvc'); + expect(scanned.headerSearchPaths).not.toContain('ignored'); + expect(scanned.headerSearchPaths.join('\n')).not.toContain('usr'); + const workspace = new Set([ + 'src/main.c', + 'a.c', + 'include/util.h', + 'msvc/only.h', + 'src/stdio.h', + ]); + expect( + cScopeResolver.resolveImportTarget( + 'util.h', + 'src/main.c', + workspace, + scanned, + angle('util.h'), + ), + ).toBe('include/util.h'); + expect( + cScopeResolver.resolveImportTarget( + 'only.h', + 'src/main.c', + workspace, + scanned, + angle('only.h'), + ), + ).toBeNull(); + expect( + cScopeResolver.resolveImportTarget('only.h', 'a.c', workspace, scanned, angle('only.h')), + ).toBe('msvc/only.h'); + expect( + cScopeResolver.resolveImportTarget( + 'stdio.h', + 'src/main.c', + workspace, + scanned, + angle('stdio.h'), + ), + ).toBeNull(); + }); + + it('honors a .clangd CompilationDatabase and still applies CompileFlags.Add', () => { + touch('extras/extra.h'); + touch('fromdb/db.h'); + touch( + '.clangd', + ['CompileFlags:', ' CompilationDatabase: build', ' Add: [-Iextras]', ''].join('\n'), + ); + touch( + 'build/compile_commands.json', + JSON.stringify([ + { + directory: TMP, + arguments: ['gcc', '-Ifromdb', '-c', 'main.c'], + file: 'main.c', + }, + ]), + ); + touch( + 'compile_commands.json', + JSON.stringify([ + { directory: TMP, arguments: ['gcc', '-Iignored-root', '-c', 'main.c'], file: 'main.c' }, + ]), + ); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + const main = scanned.translationUnits.get('main.c'); + expect(main?.headerSearchPaths).toEqual(expect.arrayContaining(['fromdb', 'extras'])); + expect(scanned.headerSearchPaths).toContain('extras'); + expect(scanned.headerSearchPaths).not.toContain('fromdb'); + expect(scanned.headerSearchPaths).not.toContain('ignored-root'); + const workspace = new Set(['main.c', 'util.h', 'lib/other.c', 'fromdb/db.h', 'extras/extra.h']); + expect( + cScopeResolver.resolveImportTarget('db.h', 'main.c', workspace, scanned, angle('db.h')), + ).toBe('fromdb/db.h'); + // A header beside the listed translation unit uses that unit's -I. + expect( + cScopeResolver.resolveImportTarget('db.h', 'util.h', workspace, scanned, angle('db.h')), + ).toBe('fromdb/db.h'); + // A file in another directory is not covered by this database entry. + expect( + cScopeResolver.resolveImportTarget('db.h', 'lib/other.c', workspace, scanned, angle('db.h')), + ).toBeNull(); + }); + + it('reads c_cpp_properties.json includePath when no compilation database exists', () => { + touch('src/main.c'); + touch( + '.vscode/c_cpp_properties.json', + JSON.stringify({ + configurations: [ + { + name: 'Linux', + includePath: ['${workspaceFolder}/include', '${workspaceFolder}/**'], + compileCommands: '${workspaceFolder}/missing/compile_commands.json', + }, + ], + }), + ); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(scanned.headerSearchPaths).toContain('include'); + expect(scanned.headerSearchPaths).not.toContain(''); + expect(scanned.headerSearchPaths).not.toContain('src'); + }); + + it('keeps an in-repo include root named ..headers and still drops a parent escape', () => { + touch('..headers/util.h'); + touch('compile_flags.txt', ['-I..headers', '-I..', '-I../outside'].join('\n')); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(scanned.headerSearchPaths).toContain('..headers'); + expect(scanned.headerSearchPaths).not.toContain('..'); + expect(scanned.headerSearchPaths.join('\n')).not.toContain('../outside'); + const workspace = new Set(['src/main.c', '..headers/util.h']); + expect( + cScopeResolver.resolveImportTarget( + 'util.h', + 'src/main.c', + workspace, + scanned, + angle('util.h'), + ), + ).toBe('..headers/util.h'); + }); + + it('reads compile_flags.txt and .ccls as the fallback flag files', () => { + touch( + 'compile_flags.txt', + ['-Iinclude', '-isystem', '/usr/include', '-iquote', 'private'].join('\n'), + ); + touch('.ccls', ['%clang', '-Ithird'].join('\n')); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(scanned.headerSearchPaths).toEqual(expect.arrayContaining(['include', 'third'])); + expect(scanned.userHeaderSearchPaths).toEqual(['private']); + expect(scanned.headerSearchPaths.join('\n')).not.toContain('usr'); + }); +}); + +describe('C/C++ workspace import resolution', () => { + const files = new Set([ + 'src/stdio.h', + 'src/cstdio.h', + 'include/util.h', + 'include/util.hpp', + 'src/main.c', + 'src/main.cpp', + ]); + + it('lets a quoted include resolve and refuses an angle include that only matches by name', () => { + touch('src/stdio.h'); + touch('include/util.h'); + touch('src/main.c'); + const loaded = cScopeResolver.loadResolutionConfig?.(TMP); + expect(loaded).toEqual( + expect.objectContaining({ headerSearchPaths: expect.arrayContaining(['include']) }), + ); + expect( + cScopeResolver.resolveImportTarget('stdio.h', 'src/main.c', files, loaded, angle('stdio.h')), + ).toBeNull(); + expect( + cScopeResolver.resolveImportTarget('util.h', 'src/main.c', files, loaded, quoted('util.h')), + ).toBe('include/util.h'); + }); + + it('resolves an angle include on one declared include root', () => { + const config = { + headers: new Set(['include/util.h', 'src/stdio.h']), + headerSearchPaths: ['include'], + userHeaderSearchPaths: [], + }; + expect( + cScopeResolver.resolveImportTarget('util.h', 'src/main.c', files, config, angle('util.h')), + ).toBe('include/util.h'); + expect( + cppScopeResolver.resolveImportTarget( + 'util.hpp', + 'src/main.cpp', + files, + { + headers: new Set(['include/util.hpp', 'src/cstdio.h']), + headerSearchPaths: ['include'], + userHeaderSearchPaths: [], + }, + angle('util.hpp'), + ), + ).toBe('include/util.hpp'); + expect( + cppScopeResolver.resolveImportTarget( + 'cstdio.h', + 'src/main.cpp', + files, + { + headers: new Set(['include/util.hpp', 'src/cstdio.h']), + headerSearchPaths: ['include'], + userHeaderSearchPaths: [], + }, + angle('cstdio.h'), + ), + ).toBeNull(); + }); + + it('accepts a raw header set so the import-target bench shape still resolves', () => { + const sources = new Set(['src/main.c']); + const headers = new Set(['include/util.h', 'src/stdio.h']); + expect(cScopeResolver.resolveImportTarget('util.h', 'src/main.c', sources, headers)).toBe( + 'include/util.h', + ); + expect( + cScopeResolver.resolveImportTarget( + 'stdio.h', + 'src/main.c', + sources, + headers, + angle('stdio.h'), + ), + ).toBeNull(); + }); + + it('keeps -iquote off the angle search', () => { + const config = { + headers: new Set(['private/util.h']), + headerSearchPaths: [], + userHeaderSearchPaths: ['private'], + }; + const workspace = new Set(['src/main.c', 'private/util.h']); + expect( + cScopeResolver.resolveImportTarget( + 'util.h', + 'src/main.c', + workspace, + config, + quoted('util.h'), + ), + ).toBe('private/util.h'); + expect( + cScopeResolver.resolveImportTarget( + 'util.h', + 'src/main.c', + workspace, + config, + angle('util.h'), + ), + ).toBeNull(); + }); +}); + +describe('C/C++ include robustness', () => { + it('tries the shallower implicit include root first', () => { + touch('include/util.h'); + touch('deps/include/util.h'); + touch('src/main.c'); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(scanned.headerSearchPaths.indexOf('include')).toBeLessThan( + scanned.headerSearchPaths.indexOf('deps/include'), + ); + const workspace = new Set(['src/main.c', 'include/util.h', 'deps/include/util.h']); + expect( + cScopeResolver.resolveImportTarget( + 'util.h', + 'src/main.c', + workspace, + scanned, + angle('util.h'), + ), + ).toBe('include/util.h'); + }); + + it('keeps Windows backslashes in a compile_commands command string', () => { + touch('include/util.h'); + touch('src/main.c'); + touch( + 'compile_commands.json', + JSON.stringify([ + { + directory: join(TMP, 'src'), + file: 'main.c', + command: 'gcc -I..\\include -c main.c', + }, + ]), + ); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(scanned.translationUnits.get('src/main.c')?.headerSearchPaths).toContain('include'); + const workspace = new Set(['src/main.c', 'include/util.h']); + expect( + cScopeResolver.resolveImportTarget( + 'util.h', + 'src/main.c', + workspace, + scanned, + angle('util.h'), + ), + ).toBe('include/util.h'); + }); + + it('does not treat bazel-out include directories as search roots', () => { + touch('include/api.h'); + touch('bazel-out/k8-fastbuild/bin/include/api.h'); + touch('src/main.c'); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(scanned.headers).toContain('include/api.h'); + expect(scanned.headers).not.toContain('bazel-out/k8-fastbuild/bin/include/api.h'); + expect(scanned.headerSearchPaths).toContain('include'); + expect(scanned.headerSearchPaths.join('\n')).not.toContain('bazel-out'); + const workspace = new Set([ + 'src/main.c', + 'include/api.h', + 'bazel-out/k8-fastbuild/bin/include/api.h', + ]); + expect( + cScopeResolver.resolveImportTarget('api.h', 'src/main.c', workspace, scanned, angle('api.h')), + ).toBe('include/api.h'); + }); + + it('falls through to compile_flags.txt when compile_commands.json is not JSON', () => { + touch('compile_commands.json', '{ this is not json'); + touch('compile_flags.txt', '-Iinclude\n'); + touch('include/util.h'); + touch('src/main.c'); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(scanned.translationUnits.size).toBe(0); + expect(scanned.headerSearchPaths).toContain('include'); + const workspace = new Set(['src/main.c', 'include/util.h']); + expect( + cScopeResolver.resolveImportTarget( + 'util.h', + 'src/main.c', + workspace, + scanned, + angle('util.h'), + ), + ).toBe('include/util.h'); + }); + + it('does not let an angle include climb out of its search root', () => { + const config = { + headers: new Set(['src/stdio.h', 'include/util.h']), + headerSearchPaths: ['include'], + userHeaderSearchPaths: [], + }; + const workspace = new Set(['src/main.c', 'src/stdio.h', 'include/util.h']); + expect( + cScopeResolver.resolveImportTarget( + '../../src/stdio.h', + 'src/main.c', + workspace, + config, + angle('../../src/stdio.h'), + ), + ).toBeNull(); + }); +}); + +describe('C/C++ monorepo config', () => { + function resolveAngle( + config: ReturnType, + targetRaw: string, + fromFile: string, + workspace: ReadonlySet, + ): string | null { + return cScopeResolver.resolveImportTarget( + targetRaw, + fromFile, + workspace, + config, + angle(targetRaw), + ); + } + + it('reads a compile_commands.json in each sub-project and lets the nearest one win', () => { + touch('libs/a/public/a.h'); + touch('libs/b/api/b.h'); + touch('libs/a/src/a.c'); + touch('libs/b/src/b.c'); + touch( + 'compile_commands.json', + JSON.stringify([ + { directory: TMP, file: 'libs/a/src/a.c', arguments: ['cc', '-Iroot-only', '-c'] }, + ]), + ); + touch( + 'libs/a/compile_commands.json', + JSON.stringify([ + { directory: join(TMP, 'libs/a'), file: 'src/a.c', arguments: ['cc', '-Ipublic', '-c'] }, + ]), + ); + touch( + 'libs/b/build/compile_commands.json', + JSON.stringify([ + { directory: join(TMP, 'libs/b'), file: 'src/b.c', arguments: ['cc', '-Iapi', '-c'] }, + ]), + ); + const config = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(config.translationUnits.get('libs/a/src/a.c')?.headerSearchPaths).toEqual([ + 'libs/a/public', + ]); + expect(config.translationUnits.get('libs/b/src/b.c')?.headerSearchPaths).toEqual([ + 'libs/b/api', + ]); + const workspace = new Set([ + 'libs/a/src/a.c', + 'libs/b/src/b.c', + 'libs/a/public/a.h', + 'libs/b/api/b.h', + ]); + expect(resolveAngle(config, 'a.h', 'libs/a/src/a.c', workspace)).toBe('libs/a/public/a.h'); + expect(resolveAngle(config, 'b.h', 'libs/b/src/b.c', workspace)).toBe('libs/b/api/b.h'); + expect(resolveAngle(config, 'b.h', 'libs/a/src/a.c', workspace)).toBeNull(); + }); + + it('scopes a sub-project compile_flags.txt to its own subtree', () => { + touch('libs/a/compile_flags.txt', '-Ipublic\n-iquote\nquoted\n'); + touch('libs/a/public/a.h'); + touch('libs/a/src/a.c'); + touch('libs/b/src/b.c'); + const config = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(config.directoryScopes.get('libs/a')).toEqual({ + headerSearchPaths: ['libs/a/public'], + userHeaderSearchPaths: ['libs/a/quoted'], + }); + expect(config.headerSearchPaths).not.toContain('libs/a/public'); + const workspace = new Set(['libs/a/src/a.c', 'libs/b/src/b.c', 'libs/a/public/a.h']); + expect(resolveAngle(config, 'a.h', 'libs/a/src/a.c', workspace)).toBe('libs/a/public/a.h'); + expect(resolveAngle(config, 'a.h', 'libs/b/src/b.c', workspace)).toBeNull(); + }); + + it('reads CMake include_directories and target_include_directories by visibility', () => { + touch( + 'CMakeLists.txt', + [ + 'project(mono C)', + 'include_directories(common)', + 'add_subdirectory(libs/net)', + 'add_subdirectory(libs/other)', + 'add_executable(app app/main.c)', + 'target_link_libraries(app PRIVATE net)', + ].join('\n'), + ); + touch( + 'libs/net/CMakeLists.txt', + [ + '# include_directories(commented-out)', + 'add_library(net src/net.c)', + 'target_include_directories(net', + ' PUBLIC $ $', + ' PRIVATE src/internal ${CMAKE_CURRENT_BINARY_DIR}/gen', + ' INTERFACE "${PROJECT_SOURCE_DIR}/shared")', + ].join('\n'), + ); + touch('common/common.h'); + touch('shared/shared.h'); + touch('libs/net/api/net.h'); + touch('libs/net/src/internal/internal.h'); + touch('libs/net/src/net.c'); + touch('app/main.c'); + touch('include/guess.h'); + touch('libs/other/src/other.c'); + touch('libs/other/CMakeLists.txt', 'add_library(other src/other.c)\n'); + const config = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(config.headerSearchPaths).toEqual(['common']); + expect(config.directoryScopes.get('libs/net')?.headerSearchPaths).toEqual([ + 'common', + 'libs/net/api', + 'libs/net/src/internal', + ]); + const workspace = new Set([ + 'app/main.c', + 'libs/net/src/net.c', + 'common/common.h', + 'shared/shared.h', + 'libs/net/api/net.h', + 'libs/net/src/internal/internal.h', + 'include/guess.h', + 'libs/other/src/other.c', + ]); + expect(resolveAngle(config, 'net.h', 'app/main.c', workspace)).toBe('libs/net/api/net.h'); + expect(resolveAngle(config, 'shared.h', 'app/main.c', workspace)).toBe('shared/shared.h'); + expect(resolveAngle(config, 'net.h', 'libs/other/src/other.c', workspace)).toBeNull(); + expect(resolveAngle(config, 'shared.h', 'libs/other/src/other.c', workspace)).toBeNull(); + expect(resolveAngle(config, 'common.h', 'libs/net/src/net.c', workspace)).toBe( + 'common/common.h', + ); + expect(resolveAngle(config, 'internal.h', 'libs/net/src/net.c', workspace)).toBe( + 'libs/net/src/internal/internal.h', + ); + expect(resolveAngle(config, 'internal.h', 'app/main.c', workspace)).toBeNull(); + // Declared roots exist, so the implicit `include/` guess is off. + expect(resolveAngle(config, 'guess.h', 'app/main.c', workspace)).toBeNull(); + }); + + it('keeps the implicit include roots off a file the compilation database lists', () => { + touch('include/guess.h'); + touch('src/main.c'); + touch( + 'compile_commands.json', + JSON.stringify([{ directory: TMP, file: 'src/main.c', arguments: ['cc', '-c'] }]), + ); + const config = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(config.translationUnits.get('src/main.c')?.headerSearchPaths).toEqual([]); + expect(config.headerSearchPaths).toEqual(['include']); + const workspace = new Set(['src/main.c', 'include/guess.h']); + expect(resolveAngle(config, 'guess.h', 'src/main.c', workspace)).toBeNull(); + // The header is not a database key. Implicit `include/` must not bind it. + expect(resolveAngle(config, 'guess.h', 'include/other.h', workspace)).toBeNull(); + }); + + it('uses the listed translation unit -I for a header in the same directory', () => { + touch('generated/real.h'); + touch('include/stdio.h'); + touch('src/main.c'); + touch('src/util.h'); + touch( + 'compile_commands.json', + JSON.stringify([ + { + directory: TMP, + file: 'src/main.c', + arguments: ['cc', '-Igenerated', '-c', 'src/main.c'], + }, + ]), + ); + const config = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + const workspace = new Set(['src/main.c', 'src/util.h', 'generated/real.h', 'include/stdio.h']); + expect(resolveAngle(config, 'stdio.h', 'src/main.c', workspace)).toBeNull(); + expect(resolveAngle(config, 'real.h', 'src/main.c', workspace)).toBe('generated/real.h'); + expect(resolveAngle(config, 'stdio.h', 'src/util.h', workspace)).toBeNull(); + expect(resolveAngle(config, 'real.h', 'src/util.h', workspace)).toBe('generated/real.h'); + }); + + it('does not install implicit include roots for a root flag file with no -I', () => { + touch('include/stdio.h'); + touch('src/main.c'); + touch('compile_flags.txt', '-Wall\n'); + const config = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(config.headerSearchPaths).toEqual([]); + const workspace = new Set(['src/main.c', 'include/stdio.h']); + expect(resolveAngle(config, 'stdio.h', 'src/main.c', workspace)).toBeNull(); + }); + + it('ignores include_directories inside a CMake bracket comment', () => { + touch('decoy/sys.h'); + touch('src/main.c'); + touch('CMakeLists.txt', '#[[\ninclude_directories(decoy)\n]]\n'); + const config = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(config.headerSearchPaths).not.toContain('decoy'); + const workspace = new Set(['src/main.c', 'decoy/sys.h']); + expect(resolveAngle(config, 'sys.h', 'src/main.c', workspace)).toBeNull(); + }); + + it('does not treat ${workspaceFolder}/** as an empty search root', () => { + touch('include/util.h'); + touch('src/main.c'); + touch( + '.vscode/c_cpp_properties.json', + JSON.stringify({ + configurations: [{ name: 'Linux', includePath: ['${workspaceFolder}/**'] }], + }), + ); + const config = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(config.headerSearchPaths).toEqual(['include']); + expect(config.headerSearchPaths).not.toContain(''); + const workspace = new Set(['src/main.c', 'include/util.h']); + expect(resolveAngle(config, 'util.h', 'src/main.c', workspace)).toBe('include/util.h'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts index 3f2761d50..aa980622f 100644 --- a/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts @@ -108,16 +108,20 @@ describe('C++ import interpretation (interpretCppImport)', () => { '@import.kind': capt('@import.kind', 'wildcard'), '@import.source': capt('@import.source', 'header.hpp'), }); - expect(result).toEqual({ kind: 'wildcard', targetRaw: 'header.hpp' }); + // isSystem is false for quoted #include "..." (no @import.system capture). + expect(result).toEqual({ kind: 'wildcard', targetRaw: 'header.hpp', isSystem: false }); }); - it('returns null for system headers', () => { + it('interprets system header as wildcard with isSystem:true', () => { + // The interpreter no longer returns null for system headers — it returns a + // ParsedImport with isSystem:true. The resolver (cppScopeResolver) is the + // layer that refuses to suffix-match system headers against workspace files. const result = interpretCppImport({ '@import.kind': capt('@import.kind', 'wildcard'), '@import.source': capt('@import.source', 'iostream'), '@import.system': capt('@import.system', 'true'), }); - expect(result).toBeNull(); + expect(result).toEqual({ kind: 'wildcard', targetRaw: 'iostream', isSystem: true }); }); it('interprets named import (using std::vector)', () => { diff --git a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts index bf73a6a76..fd29e7eea 100644 --- a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts +++ b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts @@ -342,25 +342,43 @@ const CASES: ReadonlyMap = new Map([ [ SupportedLanguages.C, { + // Workspace has a local `src/stdio.h` that shadows the system header. + // `#include ` (angle-bracket, isSystem:true) must NOT resolve to + // it. `#include "./stdio.h"` (quoted relative form, isSystem:false) from + // `src/main.c` DOES reach it via sibling lookup — the decoy-reachability + // proof. Using './stdio.h' for reachesDecoy vs 'stdio.h' for external lets + // the parsedImport factory distinguish the two arms. files: ['src/stdio.h', 'include/util.h', 'src/main.c'], fromFile: 'src/main.c', resolutionConfig: undefined, external: 'stdio.h', decoy: 'src/stdio.h', - reachesDecoy: 'util.h', + reachesDecoy: './stdio.h', + parsedImport: (targetRaw) => ({ + kind: 'wildcard', + targetRaw, + // Bare name → angle-bracket system include; relative path → quoted local. + isSystem: !targetRaw.startsWith('.'), + }), }, ], [ SupportedLanguages.CPlusPlus, { + // Same shape as C: a local `src/cstdio.h` that shadows the C++ system + // header. `#include ` (isSystem:true) must NOT resolve to it; + // `#include "./cstdio.h"` (isSystem:false) from `src/main.cpp` DOES. files: ['src/cstdio.h', 'include/util.hpp', 'src/main.cpp'], fromFile: 'src/main.cpp', resolutionConfig: undefined, - // `cstdio` with no extension would miss the decoy on spelling alone and - // post a pass that measures nothing; the header spelling is the real test. external: 'cstdio.h', decoy: 'src/cstdio.h', - reachesDecoy: 'util.hpp', + reachesDecoy: './cstdio.h', + parsedImport: (targetRaw) => ({ + kind: 'wildcard', + targetRaw, + isSystem: !targetRaw.startsWith('.'), + }), }, ], [ @@ -415,10 +433,7 @@ const CASES: ReadonlyMap = new Map([ * Fixing one means giving that language its real algorithm the way #2953 gave * TypeScript one, then deleting its line here. */ -const KNOWN_GAPS: ReadonlyMap = new Map([ - [SupportedLanguages.C, '`stdio.h` -> `src/stdio.h`'], - [SupportedLanguages.CPlusPlus, '`cstdio.h` -> `src/cstdio.h`'], -]); +const KNOWN_GAPS: ReadonlyMap = new Map([]); /** * The six that hold it, and what earns each one. diff --git a/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts b/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts index 0f2a9f2e3..af18b0a00 100644 --- a/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts +++ b/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts @@ -389,8 +389,8 @@ const FIXTURES: ReadonlyMap = new Map< [ SupportedLanguages.CPlusPlus, { - // Same accounting as C: `resolveCppImportTarget` delegates to the C - // resolver's basename index, keyed on the same Set. + // Same accounting as C. C++ keeps its own basename index, still keyed + // on this set, so the traversal count stays a function of the set. files: ['include/util.hpp', 'src/helper.hpp', 'src/main.cpp'], fromFile: 'src/main.cpp', resolutionConfig: undefined,