perf: make cache identities portable and streamline hot paths - #261
perf: make cache identities portable and streamline hot paths#261lzehrung wants to merge 27 commits into
Conversation
8d257c9 to
69dc8be
Compare
There was a problem hiding this comment.
Pull request overview
This PR refactors the disk cache to persist project-relative identities (and migrate legacy absolute-path artifacts), adds stricter confinement/validation when hydrating cached state, and streamlines several hot paths (batch writes, reduced scans, and improved query-index candidate selection). It also expands CLI/config surfaces for cache placement and updates documentation and tests accordingly.
Changes:
- Make persisted cache artifacts portable by storing project-relative paths and migrating legacy absolute-path entries (module cache, manifests, project snapshots, duplicate unit cache, bloom sidecars).
- Harden cache hydration by confining rehydrated paths/handles to the active project root and rejecting tampered/out-of-root persisted data.
- Improve performance on hot paths (batch module-cache/duplicate-cache writes, reduce redundant scans, improved query-index candidate selection and fallback parallelism).
Reviewed changes
Copilot reviewed 40 out of 40 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/query-index.test.ts | Adds diagnostics coverage for query-index candidate selection (files vs chunks). |
| tests/project-file-discovery.test.ts | Adds coverage for safe symlink traversal when explicitly including otherwise-ignored directories. |
| tests/node-modules-and-paths.test.ts | Adds incremental-cache coverage for node-modules edge refresh when package targets change. |
| tests/disk-cache-sqlite.test.ts | Expands sqlite cache tests for schema bumps, batch rollback, and portable duplicate identities/handles. |
| tests/cli-options-validation.test.ts | Adds parsing coverage for Git range value options. |
| tests/cache-path-confinement.test.ts | New suite asserting cache hydration confinement for module cache, snapshots, manifests, bloom sidecars, and reexports. |
| tests/cache-modes.test.ts | Updates cache row expectations to reflect relative keys and new cache versioning. |
| tests/cache-invalidation.test.ts | Updates invalidation expectations and adds confinement + stale snapshot/bloom recovery scenarios. |
| tests/agent-session.test.ts | Updates sidecar/snapshot versions and adds epoch drift invalidation coverage. |
| tests/agent-search.test.ts | Adds phrase-boost regression coverage for deduped rank-token joins. |
| src/util/projectFiles.ts | Improves safe-symlink discovery handling with include overrides and reduces repeated matcher construction. |
| src/indexer/types.ts | Adds languageExtensions to ProjectIndex and introduces cacheLocation in BuildOptions. |
| src/indexer/parse-context.ts | Threads languageExtensions through parsing preparation. |
| src/indexer/navigation.ts | Ensures navigation parsing respects languageExtensions. |
| src/indexer/finalize.ts | Normalizes/stores language extensions on the finalized index and updates cacheRoot import. |
| src/indexer/build-manifest.ts | Persists manifest entries as project-relative and records symlink directories/transients as relative. |
| src/indexer/build-index.ts | Batches module-cache writes, adds snapshot-module reuse, improves bloom hydration checks, and adjusts node-modules cache reuse behavior. |
| src/indexer/build-cache/project-snapshot.ts | Migrates snapshots, stores relative paths + signatures, adds bloom sidecar format/versioning, and tightens bloom validation. |
| src/indexer/build-cache/options.ts | Introduces CORE_ALGORITHM_EPOCH in build-option fingerprints and diffs. |
| src/indexer/build-cache/module-cache.ts | Stores module cache keys as project-relative, adds path rehydration transforms, and batches disk writes in a transaction. |
| src/indexer/build-cache/manifest.ts | Migrates v3 manifests to relative entries, transforms entries on load, and rehydrates symlink directories across moved roots. |
| src/indexer/build-cache/location.ts | New cache-root resolution logic supporting repo/user anchors and explicit overrides. |
| src/indexer/build-cache.ts | Re-exports updated cache APIs (new location + snapshot helpers). |
| src/graphs/symbol-graph-detailed.ts | Ensures detailed symbol graph build respects languageExtensions. |
| src/duplicates/units.ts | Introduces symbol-aware chunking path and batches duplicate-unit cache writes. |
| src/duplicates/unitCache.ts | Stores duplicate-unit cache entries/handles as project-relative and validates confinement during hydration. |
| src/config.ts | Adds cache location config (project + user config) and plumbs cache config into loaded config. |
| src/cli/viewer.ts | Updates cacheRoot import to the new location module. |
| src/cli/options.ts | Treats --cache-dir as a valued option and shares it across commands. |
| src/cli/invocationContext.ts | Plumbs --cache-dir and config cache.location into BuildOptions. |
| src/cli/inspect.ts | Uses computed cacheRoot for default cache index path. |
| src/cli/help.ts | Documents --cache-dir and cache location precedence/semantics. |
| src/cli/doctor.ts | Adds cache path/anchor diagnostics to doctor output. |
| src/chunking/chunkFile.ts | Adds symbol-chunk output while preserving existing chunkFile() behavior. |
| src/agent/search.ts | Improves query-index candidate scoring/snippets and parallelizes fallback scanning; avoids queue shift hot path. |
| src/agent/query-index/store.ts | Adds a unified candidate-chunk query and sets sqlite synchronous mode. |
| src/agent/query-index/candidates.ts | Reworks candidate scoring to return richer candidate data (score + matched line). |
| docs/cli.md | Documents cache location behavior and precedence. |
| codegraph-skill/codegraph/SKILL.md | Updates skill docs to reflect project-boundary vs cache-location behavior. |
| AGENTS.md | Updates repo guidance to reflect cache anchoring while keeping --root as project boundary. |
Suppressed comments (1)
src/config.ts:207
loadCodegraphConfig()always returnscache.location(defaulting to "project") whencodegraph.config.jsonexists. That makes the CLI always setBuildOptions.cacheLocation = "project", preventing the documented/default repository-anchor behavior (repo metadata -> project fallback) for any project that has a config file but no explicit cache config. Consider only settingcachewhen it’s explicitly configured (project or user), and otherwise leaving it undefined so the normal anchor resolution can apply.
const languageExtensions = normalizeConfigLanguageExtensions(parsed.data.languages?.extensions);
const discovery = normalizeDiscoveryConfig(parsed.data.discovery);
const resolutionHints = normalizeResolutionHints(parsed.data.graph?.resolutionHints);
const graph = resolutionHints.length ? { resolutionHints } : undefined;
return {
cache: { location: parsed.data.cache?.location ?? userCacheLocation ?? "project" },
...(discovery ? { discovery } : {}),
...(graph ? { graph } : {}),
...(languageExtensions ? { languages: { extensions: languageExtensions } } : {}),
};
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| cache: z | ||
| .object({ | ||
| location: z.string().trim().min(1), | ||
| }) | ||
| .optional(), |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 41 out of 41 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/indexer/build-cache/location.ts:80
cacheRoot()falls back to the project root when the resolved cache anchor directory does not already exist. This breaks--cache-dir/CODEGRAPH_CACHE_DIR/cache.location: "user"on first use because those anchors are expected to be created later by the code that writes cache artifacts, not to be silently ignored.
export function cacheRoot(projectRoot: string, opts?: BuildOptions): string {
const root = path.resolve(projectRoot);
const resolution = resolveCacheAnchor(root, opts);
const anchor = isWritableDirectory(resolution.anchor) ? resolution.anchor : root;
const sameRoot = fileIdentityKey(anchor) === fileIdentityKey(root);
Reduce discovery and query overhead that scales with project files, wildcard definitions, safe symlink probes, reachable graph nodes, and sidecar writes. Benchmarked targeted discovery/query suites: 88 passed, 2 skipped; no output changes observed.
Collapse candidate hydration to one multi-term sidecar query, carry candidate scoring metadata into rendering, and read fallback files with bounded concurrency. Costs now scale with unique eligible chunks and bounded file batches rather than three scans per term or serial files. Targeted query/search suites: 54 passed.
Keep queue and discovery fast paths type-safe without changing observable results; costs remain bounded by queued nodes and symlink directory batches. Targeted project-file, query-index, and agent-search suites pass: 88 passed, 2 skipped. Equivalence harness on a fixed archived 384-file fixture compared the merge-base CLI with this build byte-for-byte: graph --json --stable, review --json, and search --json for validate user, security guide, and alpha validateuser all matched. Query benchmarks showed large text/hybrid gains from the single candidate query, carried rendering score, and bounded fallback; symbol/graph movements were below this host's cold/warm noise floor. Documentation scenarios read three files and make one tool call, so their 480-520ms versus 2-3ms wall times measure process startup, not discovery cost.
…d one
buildDoctorReport() called resolveCacheAnchor() and cacheRoot() independently,
so when cacheRoot() falls back to the project root (non-writable anchor) or
reuses a legacy in-project cache, the reported anchor/layer disagreed with
the actual cache path. Added resolveCacheLocation() as the single source of
truth returning {path, anchor, layer} together; cacheRoot() now derives from
it and doctor uses it directly.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 43 out of 43 changed files in this pull request and generated no new comments.
Suppressed comments (9)
src/indexer/build-cache/project-snapshot.ts:512
- A current portable snapshot keeps the absolute root where it was written. After moving a project, any changed file prevents whole-snapshot reuse and this check then rejects the new per-file snapshot hydration for all unchanged modules, despite their relative paths and signatures being valid. Remove the provenance-root equality check here; confinement is already enforced while paths are transformed.
!projectRootMatches(projectRoot, payload.projectRoot) ||
src/indexer/build-cache/project-snapshot.ts:566
- The bloom sidecar is also keyed by relative file paths/signatures, but this absolute provenance check rejects it after the project is moved; the fallback snapshot repeats the same check at line 595. As a result, a partial rebuild after a move regenerates bloom filters for every unchanged file. Validate portable identity/signatures and confinement instead of requiring the old absolute root in both paths.
!projectRootMatches(projectRoot, payload.projectRoot) ||
src/cli/options.ts:131
--cache-diris now accepted as a shared build option, but core handlers such asindex,graph,review,navigation,impact, andinspectbuild their own options and never read it (for example,src/cli/index.ts:88-116). Those commands therefore silently ignore both this flag and the new configured cache location. ThreadcacheDir/cacheLocationthrough every shared build path or centralize them onbuildAgentOptions().
"--cache-dir",
src/indexer/build-cache/project-snapshot.ts:238
- Legacy v4 snapshots predate the newly required
fileSignaturesfield, soObject.entries(copy.fileSignatures)throws during the migration and the catch turns every real legacy snapshot into a cache miss. The move test only changes a current payload's version, so it retains this field and does not exercise the actual old schema.
This issue also appears in the following locations of the same file:
- line 512
- line 566
for (const [file, signature] of Object.entries(copy.fileSignatures)) {
src/indexer/build-cache/location.ts:61
- The namespace still hashes the absolute project path. For a subproject using the new repository anchor, moving the repository carries the cache directory along but changes this hash, so the moved cache is no longer discoverable; the existing move test only covers a project-root cache. Derive repository-anchored namespaces from a stable identity such as the project path relative to the anchor.
export function projectCacheNamespace(projectRoot: string): string {
const rootIdentity = fileIdentityKey(path.resolve(projectRoot));
const hash = crypto.createHash("sha256").update(rootIdentity).digest("hex");
return `project-${hash}`;
src/cli/doctor.ts:325
- The new cache diagnostics are present only in JSON:
formatDoctorSummarynever readsreport.cache, so the default human-readablecodegraph doctoroutput hides the path, anchor, and layer. Add aCachesection to the formatter and a pretty-output assertion.
cache: {
path: normalizePathForDisplay(cacheResolution.path),
anchor: normalizePathForDisplay(cacheResolution.anchor),
layer: cacheResolution.layer,
},
src/config.ts:214
- Library/agent sessions do not consume this new config value:
CodeReviewSession.currentBuildOptions(src/session.ts:242-256) merges discovery, graph, and language settings but notconfig.cache.location. ConsequentlycreateAgentSession({ root })ignorescache.locationunless a caller separately copies it intobuildOptions; merge it there and include it in session identity normalization.
const cacheLocation = parsed.data.cache?.location ?? userCacheLocation;
return {
...(cacheLocation ? { cache: { location: cacheLocation } } : {}),
src/agent/query-index/store.ts:366
- This consolidated query has no candidate bound, whereas the replaced FTS helpers capped prefetching. A common term can now materialize and deserialize every matching chunk in every eligible file, then sort the entire set before
QUERY_INDEX_CANDIDATE_ROW_LIMITis applied, causing memory and latency to scale with the whole index. Preserve a bounded prefetch strategy while batching paths.
const rows = this.db
.prepare(
`
${prefix}
SELECT files.path AS path, chunks.ordinal, chunks.kind, chunks.name,
chunks.start_line, chunks.end_line, chunks.text, chunks.normalized_text
FROM chunks
JOIN files ON files.file_id = chunks.file_id
WHERE files.path IN (${placeholders})
AND (${conditions.join(" OR ")})
ORDER BY files.path, chunks.ordinal
`,
)
.all(
...(ftsTerms.length ? [ftsQuery, ...batch, ...directParameters] : [...batch, ...directParameters]),
) as Array<Record<string, unknown>>;
docs/cli.md:27
- This heading is inserted between
Default workflow:and its list, so the workflow bullets are now rendered as part of the Cache location section and the original label is orphaned. Move the cache section after the workflow list.
## Cache location
Index caches store project-relative paths, so a cache can be moved with its project. Cache selection precedence is `--cache-dir`, `CODEGRAPH_CACHE_DIR`, `cache.location` in project config (then user config), repository metadata, then the project root. `cache.location` accepts `project`, `repo`, `user`, or an absolute path; `--root` remains the project scope boundary.
…CLI, and session config
- project-snapshot.ts: guard legacy v4/v5 snapshot migration against the
missing fileSignatures field (previously threw, silently forcing a full
rebuild via the outer catch); drop the redundant projectRootMatches
root-equality gate from per-file module reuse and both bloom-filter
validators now that transformPath already confines rehydrated paths to
the current root, so portable current-version snapshots survive a
project move.
- location.ts: derive the repo-anchored cache namespace from the path
relative to the resolved anchor (not the absolute project root), so a
moved repository keeps discovering its subproject caches; explicit/user
cache locations keep absolute-path hashing since they don't travel with
the project.
- doctor.ts: add a Cache section (path/anchor/layer) to the pretty
formatter, matching the JSON report shape.
- docs/cli.md: move the Cache location section after the Default workflow
bullet list instead of splitting it.
- query-index/store.ts: bound candidateChunksForTerms' SQL prefetch with
a LIMIT across path batches, restoring the overfetch-then-cap strategy
the replaced FTS helpers had (previously unbounded, so a common term
could materialize every matching chunk before the in-memory cap).
- cli/{navigation,graph,impact,review,graphDelta,index,commandTable}.ts:
thread --cache-dir and config cache.location through every build-option
path that constructs its own BuildOptions instead of buildAgentOptions(),
so those commands stop silently ignoring both.
- session.ts: merge codegraph.config.json's cache.location into
currentBuildOptions() (matching discovery/graph/languageExtensions), and
include cacheLocation in session identity normalization so sessions with
different explicit cache locations don't collide.
Adds regression coverage for each fix in cache-invalidation.test.ts,
query-index.test.ts, session.test.ts, cli-regressions.test.ts, and
cli-command-modules.test.ts.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 53 out of 53 changed files in this pull request and generated no new comments.
Suppressed comments (5)
src/indexer/finalize.ts:35
- Keep
ProjectIndex.projectRootnormalized. Callers may pass.or another relative root, so this now makes cold indexes expose a relative root while snapshot-hydrated and empty incremental indexes expose an absolute root. It also makes later duplicate-cache/path resolution depend on the process working directory at query time.
src/duplicates/units.ts:567 - The batched writer drops
options.projectRoot, even though reads above use that override. When duplicate analysis is scoped to a project root different fromindex.projectRoot(a supported public option), rows are written relative to the index root but subsequently queried relative to the override, so the disk cache can never hit. Pass the effective collection root through the batch writer and use it for signatures, keys, and payload transforms.
if (pendingWrites.length) {
writeDuplicateUnitsBatchToCache(index, pendingWrites);
src/indexer/build-cache/location.ts:90
- Nonexistent user and environment cache directories are still the actual write targets (lines 104-111), but this marks their anchor as the project root and layer as
project. This makes the exported resolution and the new doctor diagnostics report metadata that contradicts the returned path on first use. Treatuserandenvironmentanchors as creatable, likeexplicit, or return metadata for the configured target.
const anchorWritable = resolution.layer === "explicit" || isWritableDirectory(resolution.anchor);
const anchor = anchorWritable ? resolution.anchor : root;
const effectiveLayer = anchorWritable ? resolution.layer : "project";
src/cli/doctor.ts:329
- The new cache diagnostic ignores both project and user
cache.locationconfiguration because it resolves with no build options; only environment/default anchoring is reflected. Thusdoctorcan report a different cache path from every project command. Load/pass the effective cache configuration for the target project before constructing this section (while preserving the installer call site's non-project behavior).
const cacheResolution = resolveCacheLocation(process.cwd());
src/cli/inspect.ts:123
inspectandhotspotsaccept the shared--cache-diroption, and project config can now selectcache.location, but this helper always resolves only the default location. Their index loaders likewise receive neither option, so these commands can silently build/read a different cache and report/recommend the wrong manifest path. ThreadcacheDirand configuredcacheLocationthroughInspectCommandContext, the loader options, and these metadata helpers.
function defaultCacheIndexPath(projectRoot: string): string {
return cacheRoot(projectRoot, { cache: "disk" });
}
- finalize.ts/build-index.ts: resolve normalizedProjectRoot to an absolute path and use it for ProjectIndex.projectRoot on cold/warm incremental builds, matching the always-absolute snapshot-hydrated path. Previously a caller passing a relative root (e.g. ".") got a relative projectRoot on cold builds, making later duplicate-cache path math depend on process.cwd() at query time. - duplicates/unitCache.ts + units.ts: thread a projectRoot override through writeDuplicateUnitsBatchToCache (added in this PR's write batching), matching the read path and the older single-write function. Reads already respected a projectRoot override different from index.projectRoot; the batched writer silently ignored it, so scoped duplicate analysis could never hit its own disk cache. - location.ts: treat "user" and "environment" cache anchors as creatable, like "explicit", instead of falling back to layer "project" when the target directory doesn't exist yet. The actual cache path was already correct (base/candidate computation ignores anchor for those layers); only the reported anchor/layer metadata was wrong, contradicting doctor diagnostics and the resolved path. - doctor.ts: read cache.location from a project's codegraph.config.json so the cache diagnostic matches what index/search/etc. would actually use. Uses a small dependency-free JSON peek (not the zod-based ../config.js) to stay inside the enforced <30-dist-module startup budget for the doctor command (loading config.js pushed it to 71). - inspect.ts: thread --cache-dir and configured cacheLocation through InspectCommandContext, the shared index loaders, and the cache-metadata helpers backing inspect/hotspots, so they stop silently reading the default cache location and reporting the wrong manifest path. Adds regression coverage in finalize-project-index.test.ts, cache-invalidation.test.ts, disk-cache-sqlite.test.ts, cli-command-modules.test.ts, and cli-regressions.test.ts.
1ed41af to
ea40ee8
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 54 out of 54 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
src/config.ts:214
loadCodegraphConfig()now exposescache.location, butcreateAgentSession()only merges discovery, graph, and language settings from that result (src/agent/session.ts:114-143). A direct library/MCP agent session withuseConfigenabled therefore still reads and writes the default cache while CLI and review sessions honor the configured location. Propagate the resolved cache location through the agent file plan and all index/sidecar build options.
const cacheLocation = parsed.data.cache?.location ?? userCacheLocation;
return {
...(cacheLocation ? { cache: { location: cacheLocation } } : {}),
src/cli/doctor.ts:347
- The new Cache report is not the effective cache location when only the user config sets
cache.location. Builds callloadCodegraphConfig(), which falls back to the platform user config, but doctor reads only./codegraph.config.json; in that supported setup it reports a different path/anchor/layer than commands actually use. Apply the same project-over-user lookup semantics here while preserving the startup constraint.
const cacheLocation = readProjectCacheLocation(process.cwd());
const cacheResolution = resolveCacheLocation(process.cwd(), cacheLocation ? { cacheLocation } : undefined);
src/indexer/parse-context.ts:176
- The extension map is only forwarded by the updated navigation call sites. Other reparsing paths such as
workspace-symbols.ts:170,agent/renamePreview.ts:528,impact/map.ts:52, andnavigation-references.ts:132still call this withoutindex.languageExtensions; because ordinary indexes default tokeepParsed: false, those features fail or silently lose semantic results for configured custom extensions. Thread the index mapping through every index-backedensureParsedContextcall.
src/indexer/types.ts:146 cacheLocationis a new public library build option, but the canonical library API documentation is not updated to explain its accepted values, precedence relative tocacheDir/the environment, or that absolute locations are anchors rather than final cache directories. Add this contract todocs/library-api.md; the CLI-only paragraph does not document TypeScript API behavior.
| version: MANIFEST_VERSION, | ||
| files: transformManifestEntries(projectRoot, relativeFiles, false), | ||
| transientFiles: sanitizeManifestTransientFilesForRoot(projectRoot, parsed.transientFiles), | ||
| ...(symlinkDirectories !== undefined ? { symlinkDirectories } : {}), |
…g, and languageExtensions threading
High-priority:
- build-cache/manifest.ts: rebase legacy v3 transientFiles (persisted as
absolute paths) from the manifest's stored projectRoot before
sanitizing against the active root, matching the symlinkDirectories
migration pattern. Previously every transient/additional file was
silently dropped after moving a legacy project tree.
Suppressed findings:
- agent/session.ts: merge codegraph.config.json's cache.location into
createAgentSession()'s resolved build options (discoveryOptions/
graphOptions/languageExtensions already did this); explicit
buildOptions.cacheLocation still wins. Threads through the incremental
build, the detailed-symbol-graph cache options, and the incremental
file-plan lookup.
- cli/doctor.ts: read cache.location with project-over-user precedence
(matching loadCodegraphConfig) via a small dependency-free JSON peek
at both codegraph.config.json and the platform user config, instead of
only the project file. Verified this keeps codegraph doctor under the
enforced <30-dist-module startup budget (importing ../config.js pushed
it to 71).
- indexer/parse-context.ts (ensureParsedContext) consumers: thread
index.languageExtensions through every index-backed call that was
missing it (agent/renamePreview.ts, impact/{map,callCompatibility,
referenceCache,suggestions}.ts, indexer/{navigation-goto,
navigation-references,navigation,workspace-symbols}.ts). Previously
these silently fell back to guessing a file's language from its raw
extension, which throws for configured custom extensions - caught by
each call site's try/catch and treated as a skipped/omitted file.
- indexer/types.ts + docs/library-api.md: document BuildOptions.cacheDir/
cacheLocation precedence and anchor-vs-final-path semantics, and add a
"Cache location" library-api.md section covering session config
precedence.
Adds regression coverage in cache-invalidation.test.ts (v3 transientFiles
move), agent-session.test.ts (config cache.location merge + explicit
override precedence), cli-command-modules.test.ts (doctor project- and
user-config cache.location), and workspace-symbols.test.ts (import
binding resolution in a custom-extension file, verified to fail without
the fix).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 65 out of 65 changed files in this pull request and generated no new comments.
Suppressed comments (7)
src/util/projectFiles.ts:344
- When any include glob is present, this drops every default ignore from the full
fg(["**/*"])symlink probe, even when the include only targetssrc/**. Repositories with largenode_modules, vendor, or build trees will now walk all of those directories before the later include filter removes them. Keep default ignores that are not actually reopened by an include glob, and remove only the intersecting ignored roots.
src/agent/query-index/store.ts:356 - The global, path-ordered limit can be exhausted by a common term in early path batches before chunks matching rarer terms are read. For example, with more than 8,000 early
alphachunks, a later chunk matching bothalphaandbetais omitted entirely, so the in-memory scorer cannot retain the best result. Prefetch a bounded set per term and union it, or rank term coverage in SQL before applying the global cap.
for (let offset = 0; offset < paths.length && candidates.size < normalizedLimit; offset += batchSize) {
const batch = paths.slice(offset, offset + batchSize);
const placeholders = batch.map(() => "?").join(", ");
const remaining = normalizedLimit - candidates.size;
src/indexer/build-index.ts:654
- Disabling manifest-edge reuse does not disable the per-file module cache, whose
ImportBinding.resolvedvalues also contain node-module targets. A warmbuildProjectIndex()orbuildProjectIndexFromFiles()can therefore return stale module imports after a package target changes, even though its graph edges are freshly resolved; only the incremental path currently forces every file changed. Bypass module/snapshot reuse for this mode or include resolver-environment state in the module cache signature.
src/indexer/build-cache/module-cache.ts:140 ensureSqliteVersionedTableSchemainvokes this migration callback for every valid schema version, including version 2, so every cache open now selects and loops over the entire module cache. Since build entry points close the database after each build, this adds an O(project files) startup scan to every warm invocation. Run the key backfill only when upgrading from a pre-v2 schema.
src/duplicates/unitCache.ts:215- This full-table key migration also runs on every opening of an already-v2 duplicate cache because the shared schema helper always calls
migrateTablefor valid versions. Duplicate caches can contain many rows per source file, so repeated invocations pay an unnecessary O(cache rows) startup scan. Gate this backfill on an actual pre-v2 upgrade.
const rows = db.prepare("SELECT file FROM duplicate_unit_cache").all() as Array<{ file: string }>;
const update = db.prepare("UPDATE duplicate_unit_cache SET file = ? WHERE file = ?");
for (const row of rows) {
const relative = cacheRelativePath(projectRoot, row.file);
if (relative !== row.file) update.run(relative, row.file);
src/cli/doctor.ts:338
- This lightweight reader accepts any nonempty string, while the real config schema rejects relative locations. Consequently
doctorreports a relative invalid config as an effective explicit cache path even though every real build fails validation. Apply the sameproject/repo/user/absolute-path check before returning the value.
function readCacheLocationField(configPath: string): string | undefined {
try {
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw) as { cache?: { location?: unknown } };
const location = parsed.cache?.location;
return typeof location === "string" && location.trim() ? location.trim() : undefined;
docs/library-api.md:639
- This describes
cacheDiras the final cache directory, butresolveCacheLocationnormally appendsproject-<hash>to it (and the public type documentation also calls it an anchor). Users following this API documentation will look for artifacts at the wrong path. DocumentcacheDir,CODEGRAPH_CACHE_DIR, and absolutecacheLocationconsistently as namespaced anchors.
Anchor selection precedence: `cacheDir` (an explicit final directory), then `CODEGRAPH_CACHE_DIR`,
then `cacheLocation`, then repository metadata (nearest ancestor `.git`/`.codegraph`), then the
project root. `cacheLocation` accepts `"project"` (anchor at `projectRoot`), `"user"` (anchor at
the platform user cache directory), `"repo"` (the default repository-metadata search), or an
absolute path. Unlike `cacheDir`, an absolute `cacheLocation` is an anchor, not the final cache
- util/projectFiles.ts: keep default ignores active during the symlink
probe scan for any root an include glob does not actually mention,
instead of dropping every default ignore whenever any include glob is
present. Large ignored trees (node_modules, vendor, build) no longer
get walked just because an unrelated include glob was set.
- agent/query-index/store.ts: give each search term its own bounded
prefetch budget in candidateChunksForTerms instead of one shared,
path-ordered budget. A common term matching thousands of early-path
chunks could previously exhaust the whole budget before a rarer term's
match (or a chunk matching multiple terms) later in path order was
ever read, so the in-memory scorer could never see it.
- indexer/build-index.ts: skip per-file module cache reuse on the warm
(non-incremental) build path when resolveNodeModules just turned on,
matching the incremental path (which already force-marks every file
changed for this mode). A cached ModuleIndex's ImportBinding.resolved
values are computed under whatever resolveNodeModules state was active
when the file was cached; reusing them returns stale/unresolved
node-module import targets even though graph-edge reuse was already
disabled for this mode.
- util/sqliteSchema.ts: only invoke migrateTable when the on-disk schema
is genuinely behind (missing or older), not on every open at the
already-current version. module-cache.ts's and unitCache.ts's migrate
callbacks both do an O(rows) relative-path backfill scan, which
previously ran on every warm cache open regardless of whether there
was anything to migrate.
- cli/doctor.ts: validate cache.location the same way the real Zod
schema does ("project"/"repo"/"user"/absolute path) before treating a
project or user config value as effective, so doctor does not report
a relative (schema-invalid) value as a working explicit cache path.
- indexer/types.ts + docs/library-api.md: describe cacheDir and
CODEGRAPH_CACHE_DIR consistently as namespaced anchors (not final
cache directories), matching how an absolute cacheLocation already
was documented.
Adds regression coverage in query-index.test.ts (per-term prefetch
fairness), node-modules-and-paths.test.ts (stale resolveNodeModules
cache reuse, verified to fail without the fix), sqlite-common.test.ts
(migrateTable invocation count across repeated opens), and
cli-command-modules.test.ts (doctor rejecting an invalid relative
cache.location).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 67 out of 67 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
src/util/sqliteSchema.ts:89
- A current schema marker no longer guarantees that the table exists. If
cache_schema_metadatasurvives while the table is deleted or partially restored, this branch skipsmigrateTable; callers then fail while creating indexes or preparing statements. Preserve the fast path but run the idempotentcreateTablecallback for the current-version case so a missing table is repaired.
src/indexer/build-cache/project-snapshot.ts:1127 - The newly persisted
languageExtensionsfield is not validated byisProjectIndexSnapshotPayload. A malformed cache can therefore pass this guard and later throw innormalizeLanguageExtensions(for example when a mapping value is not a string), turning cache corruption into command failure instead of a clean cache miss. Validate the record's keys and string values before hydrating it.
| function snapshotSignatureMatches( | ||
| snapshotSignature: SnapshotFileSignature, | ||
| currentSignature: Pick<FileSignature, "sig" | "gitSig">, | ||
| ): boolean { | ||
| const matchingGitSignature = | ||
| !!snapshotSignature.gitSig && !!currentSignature.gitSig && snapshotSignature.gitSig === currentSignature.gitSig; | ||
| return matchingGitSignature || snapshotSignature.sig === currentSignature.sig; |
…t reuse - project-snapshot.ts: snapshotSignatureMatches now prefers the git- or content-hash-derived cacheSig over the cheap mtime:size sig whenever both sides have one. Non-strict, non-git disk caches previously reused a stale snapshot module or bloom filter after a same-size edit whose mtime got restored to its prior value, since sig alone can't distinguish that case. cacheSig is threaded end-to-end: ProjectIndexManifestEntry now carries an optional cacheSig, toProjectIndexManifestEntry synthesizes one from gitSig when the source lacks an explicit cacheSig (disk-manifest-derived entries), and the persisted SnapshotFileSignature schema + validator gained a matching optional field. - project-snapshot.ts: fixed a related pre-existing bug found while adding regression coverage - tryLoadProjectSnapshotModules looked up payload.fileSignatures by a fileIdentityKey-normalized key, but the payload's own keys were raw case-preserved paths, so the lookup always missed on case-insensitive filesystems (Windows/macOS), making snapshot module reuse silently non-functional there. Normalize the payload's keys before lookup, matching the pattern already used for persisted bloom filters. - project-snapshot.ts: isProjectIndexSnapshotPayload now validates the persisted languageExtensions field (must be undefined or a record of string values) before hydrating it, so a malformed cache entry is treated as a clean cache miss instead of crashing later in normalizeLanguageExtensions. - sqliteSchema.ts: ensureSqliteVersionedTableSchema now runs the idempotent createTable callback (not just skips migrateTable) when the schema is already at the current version, so a table that was dropped/partially restored while its version marker survived gets repaired instead of failing later index/prepare calls. Verification: tsc --noEmit clean, eslint/prettier clean, cache-invalidation, agent-session, cli-command-modules, query-index, sqlite-common, node-modules-and-paths, cache-modes, disk-cache-sqlite, cache-path-confinement, project-file-discovery, duplicates, and cli-startup-eager-modules suites all pass (830+ tests).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 67 out of 67 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
src/indexer/build-cache/project-snapshot.ts:1271
- Incremental builds finalize
manifestEntriesfromManifestFileEntry, which does not carrycacheSig, so this serializer writes only the weakmtime:sizesignature for non-Git files even thoughprepareFileSignaturescomputed a content hash. Subsequent partial snapshot and bloom-sidecar hydration then falls back tosigand can reuse stale data after a same-size edit with restored mtime. Preserve the currentFileSignature.cacheSigin the incremental index entries before writing these artifacts.
src/indexer/build-cache/location.ts:74 - The public
BuildOptions.cacheLocationcontract accepts onlyproject,repo,user, or an absolute path, but any typo or relative value reaches this branch and is silently resolved against the process working directory. Validatepath.isAbsolute(location)here and throw the same actionable validation error used for config input, so library callers cannot accidentally write to an unintended cache anchor.
| const canReuseModuleCache = cacheEnabled && !graphOptions.resolveNodeModules; | ||
| let mod: ModuleIndex | null = canReuseModuleCache | ||
| ? tryLoadFromCache(projectRoot, file, cacheSig, opts, report) | ||
| : null; |
…e-module toggle - build-index.ts: fold resolveNodeModules into the module-cache signature (moduleCacheSignatureForFile) instead of only gating reads for one direction of the toggle. Previously a build with resolveNodeModules enabled would overwrite the same cache row (keyed only by content signature) with resolved node_modules import targets; a later default build (resolveNodeModules off) could then reuse that row and leak resolved paths where the contract expects external packages. Both directions now key to distinct rows. - build-index.ts: incremental builds finalize manifestEntries from ManifestFileEntry (graph-edge derived), which never carries cacheSig. Overlay each entry with the real FileSignature.cacheSig this build already computed (content-hash-derived for non-git files, since caching is enabled whenever this path runs) before handing manifestEntries to finalizeProjectIndex, so incremental writes preserve the same strong identity a cold build produces instead of leaving persisted snapshot/ bloom signatures to silently fall back to the weak mtime:size sig. - location.ts: resolveCacheAnchor now validates cacheLocation against the same project/repo/user/absolute-path contract the config schema already enforces, throwing an actionable error instead of silently resolving a typo'd relative string against the process working directory. Verification: tsc --noEmit clean, eslint/prettier clean, cache-invalidation, node-modules-and-paths, agent-session, query-index, cache-modes, disk-cache-sqlite, cache-path-confinement, project-file-discovery, duplicates, sqlite-common, cli-command-modules, and cli-startup-eager-modules suites all pass (418+ tests in this sweep). Each new regression test independently confirmed to fail without its fix.
Summary
Verification
npm run check