diff --git a/gitnexus/src/core/ingestion/languages/c/import-target.ts b/gitnexus/src/core/ingestion/languages/c/import-target.ts index 0cb9c2fb4..495846030 100644 --- a/gitnexus/src/core/ingestion/languages/c/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/c/import-target.ts @@ -1,5 +1,54 @@ import { dirname, join } from 'path'; +/** + * A workspace file path pre-decomposed for the suffix-match fallback: + * `original` is returned verbatim (preserving the prior `bestMatch = filePath` + * contract); `normalized` and `depth` are precomputed so the hot path does no + * per-element regex/`split`. + */ +interface CSuffixCandidate { + original: string; + normalized: string; + depth: number; +} + +/** + * Per-pass memo: workspace paths bucketed by basename (last path segment), + * keyed on the `allFilePaths` set identity. + * + * `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). + */ +const suffixIndexByPaths = new WeakMap, Map>(); + +function suffixIndex(allFilePaths: ReadonlySet): Map { + let index = suffixIndexByPaths.get(allFilePaths); + if (index === undefined) { + 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 }); + } + suffixIndexByPaths.set(allFilePaths, index); + } + return index; +} + /** * Resolve a C #include path to a file in the workspace. * @@ -41,21 +90,31 @@ export function resolveCImportTarget( // Exact match (path as-is in the workspace) if (allFilePaths.has(normalizedTarget)) return normalizedTarget; - // Suffix match: find files ending with /targetRaw or equal to targetRaw + // Suffix match: find files ending with /targetRaw or equal to targetRaw. + // A path can only match `=== normalizedTarget` or `endsWith('/'+target)` if + // its basename equals the target's last segment, so we inspect only that + // basename bucket (built once per pass) instead of scanning every workspace + // path. Match condition + tie-break (fewest path components, then + // lexicographic on the normalized path) are byte-identical to the prior scan. const suffix = '/' + normalizedTarget; + const targetBasename = normalizedTarget.slice(normalizedTarget.lastIndexOf('/') + 1); + const bucket = suffixIndex(allFilePaths).get(targetBasename); + if (bucket === undefined) return null; + let bestMatch: string | null = null; let bestDepth = Infinity; let bestNormalized = ''; - for (const filePath of allFilePaths) { - const normalized = filePath.replace(/\\/g, '/'); - if (normalized === normalizedTarget || normalized.endsWith(suffix)) { + for (const cand of bucket) { + if (cand.normalized === normalizedTarget || cand.normalized.endsWith(suffix)) { // Prefer shortest path (closest match) - const depth = normalized.split('/').length; - if (depth < bestDepth || (depth === bestDepth && normalized < bestNormalized)) { - bestDepth = depth; - bestMatch = filePath; - bestNormalized = normalized; + if ( + cand.depth < bestDepth || + (cand.depth === bestDepth && cand.normalized < bestNormalized) + ) { + bestDepth = cand.depth; + bestMatch = cand.original; + bestNormalized = cand.normalized; } } } diff --git a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts index d2e0e17f7..cb4a6424f 100644 --- a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts @@ -9,6 +9,42 @@ import { scanHeaderFiles } from './header-scan.js'; import { expandCWildcardNames, isStaticName, clearStaticNames } from './static-linkage.js'; import { applyCStaticLinkageSideChannel } from './capture-side-channel.js'; +/** + * Per-pass memo of the augmented `#include`-resolution file set + * (`allFilePaths` ∪ header `.h` paths), keyed on the two stable source sets. + * + * `resolveImportTarget` is called once per C `#include`; the old code rebuilt + * a fresh ~F-entry `Set` on EVERY call (O(R × (F+H)) inserts + GC churn) and, + * worse, defeated `resolveCImportTarget`'s own per-set suffix-index memo by + * handing it a new set identity each time. Both `allFilePaths` (built once in + * scope-resolution `run.ts`) and the header set (`loadResolutionConfig` + * result) are stable per pass, so the union is built once and reused. + * `WeakMap`-keyed → reclaimed with the pass (no cross-pass staleness). + */ +const augmentedPathsByPass = new WeakMap< + ReadonlySet, + WeakMap, ReadonlySet> +>(); + +function augmentedFilePaths( + allFilePaths: ReadonlySet, + headerPaths: ReadonlySet, +): ReadonlySet { + let byHeaders = augmentedPathsByPass.get(allFilePaths); + if (byHeaders === undefined) { + byHeaders = new WeakMap(); + augmentedPathsByPass.set(allFilePaths, byHeaders); + } + let augmented = byHeaders.get(headerPaths); + if (augmented === undefined) { + const set = new Set(allFilePaths); + for (const h of headerPaths) set.add(h); + augmented = set; + byHeaders.set(headerPaths, augmented); + } + return augmented; +} + /** * C `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by * the generic `runScopeResolution` orchestrator (RFC #909 Ring 3). @@ -55,9 +91,11 @@ export const cScopeResolver: ScopeResolver = { // targets .h files classified as C++ in language detection. const headerPaths = resolutionConfig as ReadonlySet | undefined; if (headerPaths !== undefined && headerPaths.size > 0) { - const augmented = new Set(allFilePaths); - for (const h of headerPaths) augmented.add(h); - return resolveCImportTarget(targetRaw, fromFile, augmented); + return resolveCImportTarget( + targetRaw, + fromFile, + augmentedFilePaths(allFilePaths, headerPaths), + ); } return resolveCImportTarget(targetRaw, fromFile, allFilePaths); }, diff --git a/gitnexus/src/core/ingestion/languages/c/static-linkage.ts b/gitnexus/src/core/ingestion/languages/c/static-linkage.ts index df02c3312..2cc195205 100644 --- a/gitnexus/src/core/ingestion/languages/c/static-linkage.ts +++ b/gitnexus/src/core/ingestion/languages/c/static-linkage.ts @@ -46,6 +46,41 @@ export function clearStaticNames(): void { staticNames.clear(); } +/** + * Per-pass memo: `moduleScope` → owning `ParsedFile`, keyed on the + * `parsedFiles` array identity. + * + * The shared finalize Phase-4 loop calls `expandsWildcardTo` + * (→ `expandCWildcardNames`) ONCE PER RESOLVED `#include` edge, every time + * with the SAME `parsedFiles` reference (wired at scope-resolution + * `run.ts` — `allFilePaths`/`parsedFiles` are built once per pass). The old + * `parsedFiles.find(...)` therefore did a full O(F) scan per edge → + * O(R_include × F) overall; at Linux-kernel scale (F ≈ 63k C files, tens of + * thousands of resolved includes) that is ~10^10+ comparisons on a single + * thread — the dominant term in the scope-resolution finalize grind. + * + * Building the lookup once collapses it to O(R_include + F). `WeakMap`-keyed + * on the array so the index is reclaimed with the pass — no cross-pass + * staleness (mirrors the {@link clearStaticNames} discipline for server-mode + * / multi-repo reuse), and a fresh array transparently rebuilds. + */ +const moduleScopeIndexByPass = new WeakMap>(); + +function moduleScopeIndex(parsedFiles: readonly ParsedFile[]): Map { + let index = moduleScopeIndexByPass.get(parsedFiles); + if (index === undefined) { + index = new Map(); + // First-wins to preserve `Array.find` semantics (returns the first match). + // `moduleScope` is unique per file in practice, so collisions are absent; + // the guard only formalises identical behaviour to the prior `.find`. + for (const p of parsedFiles) { + if (!index.has(p.moduleScope)) index.set(p.moduleScope, p); + } + moduleScopeIndexByPass.set(parsedFiles, index); + } + return index; +} + /** * Return the names visible through a C wildcard import (`#include`). * All module-scope defs from the target file are visible EXCEPT those @@ -55,7 +90,7 @@ export function expandCWildcardNames( targetModuleScope: ScopeId, parsedFiles: readonly ParsedFile[], ): readonly string[] { - const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope); + const target = moduleScopeIndex(parsedFiles).get(targetModuleScope); if (target === undefined) return []; const seen = new Set(); diff --git a/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts b/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts index 09461d134..c6b383bf0 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts @@ -276,11 +276,37 @@ export function isCppDefGloballyVisible(filePath: string, nodeId: string): boole * does, mirror this filter or harden registration so class/namespace * members never enter `localDefs` unqualified. */ +/** + * Per-pass memo: `moduleScope` → owning `ParsedFile`, keyed on the + * `parsedFiles` array identity. The shared finalize Phase-4 loop calls + * `expandsWildcardTo` (→ this) ONCE PER RESOLVED `#include` edge with the same + * `parsedFiles` reference; the old `parsedFiles.find(...)` was therefore O(F) + * per edge → O(R·F) overall (at kernel scale the ~25–30k `.h` headers are + * classified C++, so this fires hard — the C twin in `c/static-linkage.ts`). + * Building the lookup once collapses it to O(R+F). `WeakMap`-keyed so it is + * reclaimed with the pass (no cross-pass staleness; mirrors + * {@link clearFileLocalNames}). + */ +const moduleScopeIndexByPass = new WeakMap>(); + +function moduleScopeIndex(parsedFiles: readonly ParsedFile[]): Map { + let index = moduleScopeIndexByPass.get(parsedFiles); + if (index === undefined) { + index = new Map(); + // First-wins to preserve `Array.find` semantics (returns the first match). + for (const p of parsedFiles) { + if (!index.has(p.moduleScope)) index.set(p.moduleScope, p); + } + moduleScopeIndexByPass.set(parsedFiles, index); + } + return index; +} + export function expandCppWildcardNames( targetModuleScope: ScopeId, parsedFiles: readonly ParsedFile[], ): readonly string[] { - const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope); + const target = moduleScopeIndex(parsedFiles).get(targetModuleScope); if (target === undefined) return []; // Build nodeId → owning Scope map from the structural scope tree. diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 725106c3d..5e24e292d 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -43,6 +43,40 @@ import { populateCppUserDefinedConversions, } from './user-defined-conversions.js'; +/** + * Per-pass memo of the augmented `#include`-resolution file set + * (`allFilePaths` ∪ header paths), keyed on the two stable source sets. + * `resolveImportTarget` is called once per C++ `#include`; the old code rebuilt + * a fresh ~F-entry `Set` on every call AND defeated the shared + * `resolveCImportTarget` suffix-index memo (in `c/import-target.ts`) by handing + * it a new set identity each time. Both inputs are stable per pass, so the + * union is built once and reused. `WeakMap`-keyed → reclaimed with the pass. + * (Twin of the C resolver's `augmentedFilePaths`.) + */ +const augmentedPathsByPass = new WeakMap< + ReadonlySet, + WeakMap, ReadonlySet> +>(); + +function augmentedFilePaths( + allFilePaths: ReadonlySet, + headerPaths: ReadonlySet, +): ReadonlySet { + let byHeaders = augmentedPathsByPass.get(allFilePaths); + if (byHeaders === undefined) { + byHeaders = new WeakMap(); + augmentedPathsByPass.set(allFilePaths, byHeaders); + } + let augmented = byHeaders.get(headerPaths); + if (augmented === undefined) { + const set = new Set(allFilePaths); + for (const h of headerPaths) set.add(h); + augmented = set; + byHeaders.set(headerPaths, augmented); + } + return augmented; +} + /** * C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by * the generic `runScopeResolution` orchestrator (RFC #909 Ring 3). @@ -79,9 +113,11 @@ export const cppScopeResolver: ScopeResolver = { // detection but are importable from .cpp files via #include. const headerPaths = resolutionConfig as ReadonlySet | undefined; if (headerPaths !== undefined && headerPaths.size > 0) { - const augmented = new Set(allFilePaths); - for (const h of headerPaths) augmented.add(h); - return resolveCppImportTarget(targetRaw, fromFile, augmented); + return resolveCppImportTarget( + targetRaw, + fromFile, + augmentedFilePaths(allFilePaths, headerPaths), + ); } return resolveCppImportTarget(targetRaw, fromFile, allFilePaths); },