GitNexus/gitnexus/test/unit/analyzer-identity.test.ts
Gergő Magyar 7468cc915b
fix(analyze): replace the hand-incremented schema version with a derived DDL fingerprint (#2798) (#2808)
* feat(schema): derive a fingerprint from the DDL this build creates

`SCHEMA_FINGERPRINT` is a sha256 digest of the node and relation DDL that
`runSchemaCreationQueries` actually executes, in the same shape as the existing
`taintModelVersion` stamp (hex, sliced to 12).

It exists because `INCREMENTAL_SCHEMA_VERSION` is hand-picked and has to
*predict* whether an on-disk database matches this build's DDL. That number has
collided with `main` eight times, twice exactly — and an exact clash is the
quiet one, because the reuse gate is a strict `===`.

`EMBEDDING_SCHEMA` is deliberately excluded: its `FLOAT[N]` width comes from
`GITNEXUS_EMBEDDING_DIMS` at module load, so folding it in would make the
digest a function of the environment rather than of code, and two runs of the
same build under different env would thrash full rebuilds.

Refs #2798

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(storage): record the DDL fingerprint in RepoMeta

`RepoMeta.schemaFingerprint` stores the digest of the DDL an index's tables
were actually created from. It is the derived companion to `schemaVersion`,
not its replacement: both are compared, and both must match.

Absent means mismatch, deliberately. Grandfathering a missing fingerprint
would let an incremental top-up stamp a fresh one onto a database whose DDL
was never verified, permanently certifying exactly the wrong-shaped index the
field exists to catch. The cost is one full rebuild per pre-existing index.

The version ladder gains a note that its "re-check against origin/main before
merge" ritual now only guards *semantic* bumps. v25, v26, v30, v31 and v34 all
changed emitted ids, edges or wire formats while leaving the DDL byte-identical,
and the fingerprint cannot see any of them — but DDL collisions no longer need
renumbering.

Refs #2798

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(analyze): gate index reuse on the DDL fingerprint, not just the version (#2798)

`INCREMENTAL_SCHEMA_VERSION` is a hand-incremented integer that has to predict
a derived fact: whether the on-disk DDL matches the code's DDL. It has collided
with `main` eight times, and twice the collision was *exact*.

An exact clash is the silent one. Two builds stamp the same number over
different DDL, the `===` reuse gate reads the index as current, every
`CREATE ... TABLE` is then skipped as "already exists" (suppressed in
`runSchemaCreationQueries`), and the edges whose endpoint pair the live database
cannot hold are dropped by `fallbackRelationshipInserts`' bare `catch`. The
result is a wrong graph, with no error anywhere.

Reuse now requires the version AND the DDL fingerprint to match, in both the
pre-pipeline force-rebuild guard and the `isIncremental` predicate, and the
fingerprint is stamped alongside the version at the end of a run.

Both conditions are necessary. The fingerprint does not replace the integer:
most entries in the version ladder change emitted ids, edges or wire formats
while the DDL stays byte-identical, and a fingerprint-only gate would stop
forcing rebuilds for all of them. What it does buy is that two branches picking
the same number no longer need renumbering.

The new branch sits above the `alreadyUpToDate` fast path for the same reason
the version guard does — a clean tree at an unchanged commit would otherwise
early-return before either check ran.

Closes #2798

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(analyze): pin the DDL fingerprint gate and its two failure cases

`schema-fingerprint.test.ts` pins the properties the gate rests on: the digest
covers exactly the node and relation DDL that gets executed (recomputed from
the exported lists, so adding a table or a FROM/TO pair without the fingerprint
moving is impossible), it excludes the environment-derived embedding DDL, and
it moves when any covered string moves.

The two `incremental-orchestration` cases exercise the production path rather
than modelling it: an index carrying the *current* version with a foreign
fingerprint, and one with no fingerprint at all. Both were run against the
pre-fix tree first and both failed there with `alreadyUpToDate === true` —
the fast path swallowing the mismatch, which is the #2798 symptom exactly.

`call-summary-schema-version.test.ts` widens its gate model to two equalities.
The second argument defaults to the current fingerprint so all 33 existing
version cases read unchanged, and a new case covers the collision, the legacy
absence, and the semantic bump the fingerprint cannot see.

Refs #2798

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(review-skill): point the schema-constant check at the fingerprint, not the deleted integer

All four `gitnexus-review` SKILL.md mirrors told reviewers to verify
`INCREMENTAL_SCHEMA_VERSION` "was bumped or regenerated". That constant no longer
exists, so the instruction sent every future reviewer looking for something they
could not find — and, worse, past its replacement.

The check for graph DDL is now derived: `SCHEMA_FINGERPRINT` moves on its own, so
the question is whether the diff changed a string in `NODE_SCHEMA_QUERIES` /
`REL_SCHEMA_QUERIES`, and whether a newly added DDL array was folded into the
fingerprint at all — the one way the derived gate can still be bypassed.

What did NOT change is called out explicitly: the parse-store `SCHEMA_BUMP` and
the bench fingerprint sets are still hand-maintained and still need the
re-check-against-base ritual, and semantic changes that leave the DDL untouched
fall outside the fingerprint entirely — those rely on the analyzer runner-identity
receipt.

Found by the review swarm's docs lane. The original plan for #2798 claimed no
documentation mentioned the constant; that sweep covered five root docs and never
looked at `.claude/skills/**` or the three mirrors.

Refs #2798

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(migration): record the one-time rebuild the fingerprint switch costs

Replacing `schemaVersion` with `schemaFingerprint` means every index written by
an earlier GitNexus carries no fingerprint, reads as a mismatch, and is rebuilt
once. That is deliberate — grandfathering absence would stamp a fresh fingerprint
onto a database whose DDL was never verified — but until now it was undocumented,
so a user's first post-upgrade analyze would announce a full re-analyze with
nothing to explain it.

MIGRATION.md already sets the precedent: PR #2363's meta.json → gitnexus.json
rename was equally automatic and equally in need of an entry. This follows that
shape, and is explicit about the parts that are easy to undersell:

- the cost is per INDEX, and branch-scoped slots (#2106) each pay separately;
  on a large repository a full re-analyze is substantial, not a blip;
- rollback is safe — an older binary sees no `schemaVersion` and forces its own
  rebuild, which is a cost, never a stale graph;
- alternating between an old and a new binary rebuilds on every switch, because
  the end-of-run meta is written as a fresh literal so neither field survives the
  other's run.

The retired ladder's per-version rationale is pointed at in git history rather
than reproduced: `git show 561f913a3:.../repo-manager.ts`. That commit is an
ancestor of origin/main, so the pointer survives this branch being squash-merged.

Refs #2798

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(identity): cover workspace-linked packages in the analyzer dependency digest

`dependencyNames` enumerated `dependencies`, `optionalDependencies` and
`peerDependencies` only. `gitnexus-shared` is declared as a devDependency
(`file:../gitnexus-shared`), and in a source-mode run the build root is the
gitnexus package tree, which does not contain that sibling. So a change to
gitnexus-shared moved neither `build.digest` nor `dependencyRuntime.digest`.

That gap matters more since #2798 deleted `INCREMENTAL_SCHEMA_VERSION`. A
DDL-affecting edit there is still caught by `SCHEMA_FINGERPRINT`, but a
SEMANTIC-only edit — a new `REL_TYPES` member, say, where the relation table
carries a bare `type STRING` column so no CREATE statement moves — was covered by
nothing at all. Roughly thirty of the retired ladder's entries were exactly that
change class, and the runner-identity receipt is what now carries them.

Only checkout-local specifiers are added: `file:`, `link:`, `workspace:`,
`portal:` and npm's bare local-path shorthands. Pulling in every devDependency
was rejected — vitest, eslint and typescript would enter the digest and force a
full re-analyze on unrelated tool bumps, which is worse than the hole.

Scanning the linked sibling for the first time exposed a latent throw:
`collectArtifacts` honoured `PRUNED_RUNTIME_DIRECTORIES` only for a real
directory, so a SYMLINKED `node_modules` fell through to the payload branch and
died with "Analyzer identity input is not a file". Worktree-style dev layouts and
pnpm shared stores hit this immediately — verified in this worktree, where
`gitnexus-shared/node_modules` is such a symlink. Pruning it loses nothing:
packages beneath are still reached through `resolveDependencyPackageRoot`.

Verified: a real `analyze` in this worktree succeeds with `packageCount` 259;
editing the linked package's source moves the digest, bumping an installed
registry devDependency does not, and removing the link moves it.
`DEPENDENCY_RUNTIME_CANONICALIZATION` is deliberately not bumped — freshness
compares digests, not the label, and the input-set change already moves them.

Follow-up worth having: no fixture in the suite declares `devDependencies`, so
this has no regression test yet.

Refs #2798

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(analyze)!: delete INCREMENTAL_SCHEMA_VERSION, gate reuse on the DDL fingerprint alone

The integer and its ~180-line version ladder are gone, along with
`RepoMeta.schemaVersion`. Index reuse is now decided solely by
`SCHEMA_FINGERPRINT`; a mismatch — including the absent stamp every pre-existing
index carries — warns and forces a full re-analyze, which wipes and recreates the
database so the tables are built from the current DDL.

Deleting the integer is safe because it was already redundant: the
runner-identity guard deep-compares the whole schema-v4 receipt, including a
digest over the build tree, and forces a rebuild on ANY analyzer delta. Verified
empirically — a comment-only edit to logger.ts, with the fingerprint byte
identical, produced "runner identity changed ... forcing a full rebuild".

The fingerprint is not thereby redundant. It fires where that guard cannot: a
DDL-affecting change in `gitnexus-shared`, which is a workspace-linked
devDependency and so sat outside both digests until the companion commit closed
that gap.

Review findings folded in, each correcting a line this rewrite itself introduced
and never published:

- B1: two assertions matched a log string the rewrite had renamed; both tests
  failed. They now assert what production emits.
- B2: the pre-existing downgrade test perturbed `schemaVersion: 7`, a field this
  change deletes, so the spread carried a valid fingerprint, every guard passed,
  and the run legitimately took the fast path. It perturbs the fingerprint now,
  restoring the only integration coverage of the gate-above-the-fast-path
  ordering invariant.
- N5: duplicate `schemaFingerprint` keys silently collapsed two assertions into
  one (TS1117).
- N6: the absent-stamp message told non-git repositories their index was "built
  by an older GitNexus version" — on every run, about an index this exact build
  had just written. Non-git repos never record a fingerprint, and now the message
  says so.
- N9: the on-disk stamp is shape-checked before being echoed, so a crafted
  gitnexus.json cannot push ANSI escapes through the CLI log.
- N7: a test case that re-computed the same digest expression with its operands
  swapped, mislabelled as a randomness check on a module-level const.
- N10: comments claiming the digest "cannot collide" (it is 48 bits), pointing at
  a vector-column gate that does not exist, and asserting storage/ is free of a
  core/ dependency two lines below a core/ value import.

None of these were caught by `tsc -p tsconfig.json`, which covers src only, nor
by eslint, where no-dupe-keys is off. `tsconfig.test.json` reports all three test
defects and is not currently wired into CI.

Refs #2798

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(schema): pin that the fingerprint covers every DDL statement init executes

`SCHEMA_QUERIES` is the list `runSchemaCreationQueries` iterates — the DDL that
actually runs. The fingerprint hashes only two of its three members, and until
now no test imported `SCHEMA_QUERIES` at all, so nothing tied the two together.

A fourth member appended to that array — the one literally named for what init
executes — would have been invisible to the gate. Every existing test would still
pass, because they all recompute the digest from the same two arrays the
fingerprint already uses. An index whose gate passed would then run `initLbug`
over the old database, where `runSchemaCreationQueries` suppresses "already
exists", so the new table would never be created and its edges would be dropped
by `fallbackRelationshipInserts`' bare catch. A wrong graph, no error — exactly
the failure #2798 exists to end.

The check is a pure predicate over (executed, fingerprinted, documented
exclusions) rather than a positional `toEqual`, so `EMBEDDING_SCHEMA` is named as
an exclusion with its reason — its FLOAT[N] width is environment-derived — rather
than sitting in a list where a future reader might "fix" it by folding it in. It
asserts both directions and is order-insensitive, leaving ordering to the digest
assertion that already pins it.

The negative case is pinned in CI rather than checked by hand once: the same
predicate over a synthetic fourth member must report it. If a refactor ever makes
the predicate vacuous, that case fails even though the positive one would not.

Refs #2798

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(analyze): name the invariant the version deletion now rests on

Deleting `INCREMENTAL_SCHEMA_VERSION` moved a load-bearing guarantee into an
implicit one. Roughly thirty of the retired ladder's entries changed no DDL at
all — node ids, wire formats, resolution tiers — and the fingerprint is
structurally incapable of firing on any of them. Their only remaining cover is
the analyzer runner-identity receipt, and nothing in the suite said so.

This adds a table over the real `analyzerRunnerIdentitiesEqual` with a
well-formed schema-v4 receipt: byte-identical reuses; an entrypoint-only
difference reuses (CLI vs analyze worker); a moved build digest with unchanged
DDL forces — that case IS the invariant, commented as such; and a dependency
change, an ABI change, undefined, null, a schema-v3 legacy receipt, a missing
build section and a non-sha256 digest all fail closed.

The deleted `expect(INCREMENTAL_SCHEMA_VERSION).toBe(35)` pin is also worth
naming: it failed CI on every bump by design, which is what made an author stop
and think. Nothing replaced it. This does not restore that — a digest has no
literal to pin — but it does make the mechanism that took over the job visible to
the next person who reads the file.

Refs #2798

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(spring): pin CLASS_SCHEMA's membership in the fingerprinted DDL set

When `INCREMENTAL_SCHEMA_VERSION` went away, its sibling in
basicblock-callee-ids-schema.test.ts got a replacement assertion tying
BASICBLOCK_SCHEMA to the fingerprint's input set. This file's
`>= 23` floor was deleted with nothing put in its place.

The file still asserts CLASS_SCHEMA's CONTENT — that the `frameworkAnnotations`
column exists — but not that CLASS_SCHEMA is part of what the digest covers, and
the second is what makes an index built before that column carry a different
fingerprint and get rebuilt. Mirrors the sibling so the two read the same way.

Refs #2798

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(identity): stop a symlinked directory from aborting the whole analyze

`collectArtifacts` fused two orthogonal facts into one condition: that four
directory names never carry runtime payload, and that a symlink where a real
directory was assumed falls through to the payload branch, where
`snapshotReadableFile` stats the target, sees a directory, and throws
"Analyzer identity input is not a file".

The second was only fixed for those four names. Every other symlinked directory
in a scanned package root still aborted the run — `dist -> build`, a vendored
grammar link, anything inside a linked sibling checkout. Newly reachable,
because making workspace-linked packages scannable pointed the scanner at a live
checkout instead of an immutable registry tarball for the first time.

Split along the actual seam: prune on the NAME alone, and give symlinks their own
branch in the type dispatch, ahead of the payload branch.

Link text is recorded rather than followed. Following was rejected on three
grounds, each checked in source: the traversal is a stack with no visited set, so
a self-referential link would recurse to `runtimeDepth` — which throws, trading
one hard abort for another; `snapshotDirectory` rejects a symlink outright, so
the directory guard could not accept one without a realpath rewrite of its
canonical-path identity; and a link into an already-scanned tree double-counts
against `runtimeEntries`/`runtimeBytes`, which also throw. The cost is stated in
code: a link out of the package contributes its text, not its target's content.
Links resolving to a regular file keep the existing content digest.

The new `'unfollowed-symlink'` kind is threaded through every consumer, including
the cache validator — which re-probes with `mode: 'link'`, since the readable-file
probe resolves the target and would return null for exactly this kind, silently
failing every warm validation.

No canonicalization or cache-schema bump. Digest content changes only for trees
that previously crashed: a delta scan over all 258 scanned roots of this install
found no regular file bearing a pruned name and no symlink failing to resolve to
a file, so `dependencyRuntime.digest` is byte-identical here.

Six of the eight new tests fail against the unfixed tree with the exact production
error; all eight pass after.

Refs #2798

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(analyze): give the reuse gate a real seam and sanitize logs at the funnel

Cleanup pass over the #2798 branch. Net -183 lines.

The gate had no extracted predicate, so its own test asserted it by regex-matching
run-analyze.ts SOURCE TEXT. That pinned production formatting: one pattern froze
three back-to-back single-name imports from './lbug/schema.js', so merging them —
the obvious tidy-up — failed a test named "still imports the DDL digest itself".

`schemaFingerprintMismatch` and `isSchemaFingerprintShaped` now live in
core/lbug/schema.ts beside the constant. Not in run-analyze.ts next to
`pdgModeMismatch`, because storage/ must stay off the analyze pipeline and
mcp/resources.ts is a plausible second consumer — the same reasoning that puts
`cjkSegmentationModeMismatch` in core/search/. The regex block is gone; the test
calls the predicate. The three imports are merged.

ANSI sanitation moved from one field to the funnel. The per-field guard's own
comment stated the general hazard — gitnexus.json is parsed with no runtime shape
validation and the notice reaches console.log — while two sibling guards twelve
lines away echoed `runnerIdentity.schemaVersion` and `cjkSegmentation` from that
same file raw into the same log. `log()` now strips C0/C1 controls, covering all
seven guard messages and any written later.

Also:
- Deleted a duplicate integration test. After the downgrade test was repointed at
  `schemaFingerprint` it became the same scenario as the new one, differing only
  by an extra log assertion — which is now folded into the survivor. Saves a
  fixture and two full pipeline runs per CI pass.
- Replaced a 3-parameter set-difference helper with one set equality. Its doc was
  false at one call site (arguments semantically swapped) and it needed a fourth
  test purely to prove itself non-vacuous; set equality cannot go vacuous.
- Removed ~115 lines of runner-identity table that duplicated
  analyzer-identity.test.ts. The three genuinely uncovered cases moved there, and
  the #2798 invariant — build digest moved while the DDL did not — now asserts
  against a REAL analyzer-build-tree edit rather than a hand-built literal, which
  is strictly stronger than what it replaces.
- MIGRATION.md quoted a log line the code cannot emit; it was written before the
  placeholder changed.
- Restored the rationale on the `capabilities` docstring, which a previous pass
  replaced with its consequence — leaving a maintainer reading "duplicated by
  hand" as a wart to fix by importing, which is what the original forbade.
- Marked the `isIncremental` conjunct as belt-and-braces: `!options.force`
  short-circuits before it, so it cannot decide anything.

Refs #2798

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(analyze): force a rebuild when the vector column width changes

`CodeEmbedding.embedding` is declared `FLOAT[EMBEDDING_DIMS]`, resolved from
`GITNEXUS_EMBEDDING_DIMS` at module load. Nothing gated it. Flip the variable on
a same-commit clean tree and no guard fired at all: `alreadyUpToDate` returned
over a `FLOAT[384]` table while the process embedded at 768. The only reaction
anywhere discards the embedding CACHE and re-embeds — into a column whose type it
never revisits.

This predates #2798; `INCREMENTAL_SCHEMA_VERSION` never covered dims either. It
surfaced because the fingerprint work had to reason about why `EMBEDDING_SCHEMA`
must stay OUT of the digest: its width is environment-derived, so folding it in
would make the same build disagree with itself and thrash rebuilds. That
exclusion is correct, and it leaves the width needing its own guard.

Modelled on `cjkSegmentation`, the closest sibling: an env-resolved scalar
stamped at write time and compared by a small exported predicate that forces on
mismatch. `embeddingDimsMismatch` sits in core/lbug/schema.ts beside
`EMBEDDING_DIMS`, so the query side can adopt it without importing the analyze
pipeline — mcp/local/local-backend.ts already warns on a cjkSegmentation
disagreement and has the identical claim here, since the query path embeds at the
live width against a table of unknown width with no validation at all today.

ABSENCE IS NOT A MISMATCH, deliberately. Forcing on it would be dead code:
`embeddingDims` and `schemaFingerprint` ship together, and a missing fingerprint
already forces exactly one rebuild — which is where this stamp lands. Absence
also carries no signal here, unlike the fingerprint: a missing fingerprint means
"DDL this build cannot vouch for" and ships WITH a DDL change, whereas a missing
dims stamp means only "written before the field existed", and that run's table
agreed with that run's width. Drift requires the env to change, which absence
says nothing about. The `cjkSegmentation` trick of folding absence into the
default was unavailable — there is no width that is safe to assume for an
existing table — so the stamp is instead written unconditionally, giving absence
exactly one meaning. Malformed values are not grandfathered: null, '384', NaN and
objects all read as a mismatch and err toward a rebuild.

Refs #2798

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(mcp): warn when the served index's vector width differs from the query embedder's

The analyze side now forces a rebuild when the vector column width changes. The
query side had no equivalent: a serving process embeds a query at its own width
and searches a table whose width was fixed when the index was built. Disagree and
the user gets wrong or missing semantic results with nothing explaining why.

Mirrors the cjkSegmentation drift warning immediately above it — same warnings[]
array, same per-query recomputation, agent-visible in the tool response, and it
warns rather than refuses. A width mismatch degrades the semantic lane only;
keyword results are unaffected, so `partial` is deliberately not set.

Compares against `getEmbeddingDims()` — the width the query embedder actually
produces — NOT schema.ts's `EMBEDDING_DIMS`. The two diverge exactly when
GITNEXUS_EMBEDDING_DIMS is set on a server that embeds LOCALLY: the query path
ignores that variable and embeds at 384, so comparing against the env-derived
constant would report drift on a lane that works fine. The recorded width is what
the vector CAST actually binds.

`embeddingDimsMismatch` is imported from core/lbug/schema.js rather than
restated, so "absent is not a mismatch" cannot drift between the analyze and
query sides. That predicate was placed in schema.ts precisely so this consumer
could reach it without importing the analyze pipeline.

Two gates keep it quiet when it would be noise: it fires only for a repo where
this process actually produced a query vector, so an index analyzed without
--embeddings (or a server whose embedder is unavailable) never carries it. An
untrusted recorded value — meta.json is schema-less JSON — is reported as "an
unrecognized width" rather than echoed.

`loadMeta` is hoisted out of the neighbouring try so both diagnostics share one
read and an invalid GITNEXUS_FTS_CJK_SEGMENTATION cannot take this one down with
it.

Refs #2798

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(identity): detect an npm-linked dev dependency the specifier cannot see

`isLocallyLinkedSpecifier` admits a devDependency whose SPECIFIER is
checkout-local. `npm link <pkg>` leaves the specifier a registry range while the
node_modules entry symlinks to a checkout — locally linked, invisible to a
specifier check, so a semantic-only edit there still moves neither digest.

The obvious placement is unaffordable, measured rather than assumed: probing
every dev-only name inside collectRuntimePackages costs 1998 resolutions, not
the ~8 it looks like, because dependencyNames runs for every package in the BFS
and published tarballs retain their devDependencies. Persisted path guards go
2221 -> 11050 (+398%), and every guard is re-probed on each warm validation —
the path `status` takes.

Scoped to the root package instead. The declared-intent half is untouched and
still enumerated everywhere: it alone can emit the `<missing>` edge for a
declared link whose checkout is absent, where resolution returns null and cannot
distinguish that from an uninstalled dev tool. The new resolved-location half
runs only when `parent.root === packageRoot`, resolves through the existing
resolver so its path guards are recorded, and admits a name iff the realpath'd
root carries no node_modules segment.

Bounded against mis-fire by EXPANSION. "Not under node_modules" is a proxy for
"checkout-local"; under a relocated pnpm virtual store every dev dep passes it
and the whole dev tree folds into the receipt — against limits that THROW, so a
legitimate install would abort. Measured here: uncapped, that shape takes
259 -> 347 packages and 2250 -> 3786 guards. The cap admits at most four and
DROPS THE WHOLE CHANNEL on overflow rather than an arbitrary prefix, because the
abort comes from the transitive payload of whichever trees get folded in — four
of a mis-fired thirteen is still unbounded, and a sorted-prefix receipt would be
arbitrary. Overflow falls back to the specifier-only receipt that ships today.

Cost on this install: 259 packages unchanged, 13 dev names resolved, guards
2221 -> 2250 (+29, +1.3%). Verified against the real implementation, not just a
replay: validation guards 16295 -> 16324, packageCount and artifactCount
unchanged, and `dependencyRuntime.digest` byte-identical — so this forces no
re-analysis for anyone.

Each test fails on the defect it targets: disabling the channel kills the
npm-link and cap cases; dropping the root-only scope makes the differential
guard-count case fail at 2.8x guards; removing the specifier half kills the
`<missing>` case.

Refs #2798

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:04:30 +01:00

1565 lines
64 KiB
TypeScript

import { createHash } from 'node:crypto';
import { writeFileSync } from 'node:fs';
import { link, mkdir, readFile, readdir, symlink, unlink, writeFile } from 'node:fs/promises';
import { performance } from 'node:perf_hooks';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
_clearAnalyzerIdentityProcessCacheForTests,
_hashAnalyzerIdentityFramesForTests,
analyzerRunnerIdentitiesEqual,
captureAnalyzerIdentityBeforeLoad,
finalizeAnalyzerRunnerIdentity,
normalizeAnalyzerRootPath,
normalizeAnalyzerRunnerIdentityForComparison,
resolveAnalyzerRunnerIdentity,
} from '../../src/core/analyzer-identity.js';
import { getStoragePaths, loadMeta, saveMeta } from '../../src/storage/repo-manager.js';
import type { RepoMeta } from '../../src/storage/repo-manager.js';
import { setupMiniRepo } from '../helpers/mini-repo.js';
import { createTempDir } from '../helpers/test-db.js';
describe('analyzer runner identity', () => {
it('is versioned, resolved, and changes when the analyzer build tree changes', async () => {
const fixture = await createTempDir();
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
await mkdir(path.dirname(modulePath), { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
'{"name":"fixture-analyzer","version":"9.8.7"}\n',
);
await writeFile(path.join(fixture.dbPath, 'package-lock.json'), '{"lockfileVersion":3}\n');
await writeFile(modulePath, 'export const analyzer = 1;\n');
const cacheDirectory = path.join(fixture.dbPath, 'identity-cache');
const first = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
expect(first).toMatchObject({
schemaVersion: 4,
cliVersion: '9.8.7',
runtime: {
executablePath: expect.any(String),
version: process.version,
platform: process.platform,
architecture: process.arch,
modulesAbi: process.versions.modules ?? 'unknown',
libc: expect.any(String),
},
invokedArtifact: {
path: modulePath,
digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
build: {
kind: 'source',
rootPath: sourceRoot,
canonicalization: 'gitnexus-analyzer-build-v2',
digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
dependencyRuntime: {
manifestPath: path.join(fixture.dbPath, 'package.json'),
lockfilePath: path.join(fixture.dbPath, 'package-lock.json'),
canonicalization: 'gitnexus-analyzer-dependency-runtime-v4',
packageCount: 1,
artifactCount: 0,
digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
});
await writeFile(path.join(sourceRoot, 'new-module.ts'), 'export const changed = true;\n');
const second = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
expect(second.invokedArtifact.digest).toBe(first.invokedArtifact.digest);
expect(second.build.digest).not.toBe(first.build.digest);
expect(second.dependencyRuntime.digest).toBe(first.dependencyRuntime.digest);
// THE #2798 INVARIANT: the build digest moved while nothing else did.
// Node-id formats, wire formats, resolution tiers and emit ordering live in
// analyzer code, not in DDL, so `SCHEMA_FINGERPRINT` (lbug/schema.ts) is
// structurally incapable of firing on a change shaped like this one — it is
// a digest of the node+relation DDL and of nothing else. #2798 deleted the
// hand-incremented INCREMENTAL_SCHEMA_VERSION ladder, and roughly 30 of its
// ~35 bumps were exactly this shape: semantic, no DDL. This receipt is their
// only remaining cover, so a moved build digest MUST refuse index reuse.
// (call-summary-schema-version.test.ts holds the DDL-blind half of the split.)
expect(analyzerRunnerIdentitiesEqual(second, first)).toBe(false);
} finally {
await fixture.cleanup();
}
});
it('rejects build-tree symlinks instead of trusting unchanged link metadata', async () => {
const fixture = await createTempDir();
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
const importedTarget = path.join(fixture.dbPath, 'outside-build-input.ts');
const importedLink = path.join(sourceRoot, 'linked-input.ts');
await mkdir(path.dirname(modulePath), { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
'{"name":"fixture-analyzer","version":"9.8.7"}\n',
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
await writeFile(importedTarget, 'export const imported = 1;\n');
try {
await symlink(importedTarget, importedLink, 'file');
} catch (error) {
if (['EPERM', 'EACCES'].includes((error as NodeJS.ErrnoException).code ?? '')) return;
throw error;
}
const resolve = () =>
resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory: path.join(fixture.dbPath, 'identity-cache'),
});
expect(resolve).toThrow(/build symbolic links are not supported/);
// Changing only target bytes leaves the symlink inode/text unchanged.
// The resolver must continue to fail closed, never return an old digest.
await writeFile(importedTarget, 'export const imported = 200;\n');
expect(resolve).toThrow(/build symbolic links are not supported/);
} finally {
await fixture.cleanup();
}
});
it('versions runtime semantics and rejects a cache from another runtime variant', async () => {
const fixture = await createTempDir();
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
const cacheDirectory = path.join(fixture.dbPath, 'identity-cache');
await mkdir(path.dirname(modulePath), { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
'{"name":"fixture-analyzer","version":"9.8.7"}\n',
);
await writeFile(modulePath, Buffer.alloc(128 * 1024, 0x5a));
const first = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
expect(first.runtime).toMatchObject({
version: process.version,
platform: process.platform,
architecture: process.arch,
modulesAbi: process.versions.modules ?? 'unknown',
libc: expect.stringMatching(/\S/),
});
expect(
analyzerRunnerIdentitiesEqual(
{ ...first, runtime: { ...first.runtime, platform: `${first.runtime.platform}-other` } },
first,
),
).toBe(false);
expect(
analyzerRunnerIdentitiesEqual(
{
...first,
runtime: { ...first.runtime, architecture: `${first.runtime.architecture}-other` },
},
first,
),
).toBe(false);
expect(
analyzerRunnerIdentitiesEqual(
{
...first,
runtime: { ...first.runtime, modulesAbi: `${first.runtime.modulesAbi}-other` },
},
first,
),
).toBe(false);
expect(
analyzerRunnerIdentitiesEqual(
{ ...first, runtime: { ...first.runtime, libc: `${first.runtime.libc}-other` } },
first,
),
).toBe(false);
const [cacheFile] = await readdir(cacheDirectory);
const cachePath = path.join(cacheDirectory, cacheFile);
const envelope = JSON.parse(await readFile(cachePath, 'utf8')) as {
payload: {
schemaVersion: number;
runtimeVariant: {
nodeVersion: string;
platform: string;
architecture: string;
modulesAbi: string;
libc: string;
};
};
checksum: string;
};
expect(envelope.payload).toMatchObject({
schemaVersion: 6,
runtimeVariant: {
nodeVersion: process.version,
platform: process.platform,
architecture: process.arch,
modulesAbi: process.versions.modules ?? 'unknown',
libc: first.runtime.libc,
},
});
envelope.payload.runtimeVariant.platform = `${process.platform}-stale-cache`;
envelope.checksum = `sha256:${createHash('sha256')
.update(JSON.stringify(envelope.payload))
.digest('hex')}`;
await writeFile(cachePath, `${JSON.stringify(envelope)}\n`);
_clearAnalyzerIdentityProcessCacheForTests();
let hashedBytes = 0;
const afterIncompatibleCache = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
onHashedInput: ({ bytes }) => {
hashedBytes += bytes;
},
});
expect(afterIncompatibleCache).toEqual(first);
expect(hashedBytes).toBeGreaterThanOrEqual(128 * 1024);
} finally {
await fixture.cleanup();
}
});
it('disables the default persistent cache without getuid but trusts an explicit override', async () => {
const fixture = await createTempDir();
const originalGetuid = Object.getOwnPropertyDescriptor(process, 'getuid');
const originalEnvironment = {
TMPDIR: process.env.TMPDIR,
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
};
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
const tempRoot = path.join(fixture.dbPath, 'tmp');
const explicitCache = path.join(fixture.dbPath, 'operator-cache');
await mkdir(path.dirname(modulePath), { recursive: true });
await mkdir(tempRoot, { mode: 0o700 });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
'{"name":"fixture-analyzer","version":"9.8.7"}\n',
);
await writeFile(modulePath, Buffer.alloc(64 * 1024, 0x33));
process.env.TMPDIR = tempRoot;
delete process.env.XDG_RUNTIME_DIR;
Object.defineProperty(process, 'getuid', {
value: undefined,
configurable: true,
enumerable: true,
writable: true,
});
const hashedWith = (cacheDirectory?: string): number => {
let bytes = 0;
resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
...(cacheDirectory ? { cacheDirectory } : {}),
onHashedInput: (input) => {
bytes += input.bytes;
},
});
return bytes;
};
expect(hashedWith()).toBeGreaterThanOrEqual(64 * 1024);
// Cross-platform process-local reuse remains available even when secure
// default persistence cannot be proved.
expect(hashedWith()).toBe(0);
expect(await readdir(tempRoot)).toEqual([]);
_clearAnalyzerIdentityProcessCacheForTests();
expect(hashedWith(explicitCache)).toBeGreaterThanOrEqual(64 * 1024);
_clearAnalyzerIdentityProcessCacheForTests();
expect(hashedWith(explicitCache)).toBe(0);
} finally {
if (originalGetuid) Object.defineProperty(process, 'getuid', originalGetuid);
else Reflect.deleteProperty(process, 'getuid');
if (originalEnvironment.TMPDIR === undefined) delete process.env.TMPDIR;
else process.env.TMPDIR = originalEnvironment.TMPDIR;
if (originalEnvironment.XDG_RUNTIME_DIR === undefined) delete process.env.XDG_RUNTIME_DIR;
else process.env.XDG_RUNTIME_DIR = originalEnvironment.XDG_RUNTIME_DIR;
await fixture.cleanup();
}
});
it('supports only an absolute, pre-provisioned, external operator-trusted cache directory', async () => {
const fixture = await createTempDir();
const protectedCache = await createTempDir();
const previous = process.env.GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR;
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
await mkdir(path.dirname(modulePath), { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
'{"name":"fixture-analyzer","version":"9.8.7"}\n',
);
await writeFile(modulePath, Buffer.alloc(64 * 1024, 0x37));
const url = pathToFileURL(modulePath).href;
const hashedBytes = (): number => {
let bytes = 0;
resolveAnalyzerRunnerIdentity(url, {
onHashedInput: (input) => {
bytes += input.bytes;
},
});
return bytes;
};
process.env.GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR = protectedCache.dbPath;
_clearAnalyzerIdentityProcessCacheForTests();
expect(hashedBytes()).toBeGreaterThanOrEqual(64 * 1024);
_clearAnalyzerIdentityProcessCacheForTests();
expect(hashedBytes()).toBe(0);
for (const invalid of [
'relative/cache',
path.join(fixture.dbPath, 'missing-cache'),
fixture.dbPath,
sourceRoot,
]) {
process.env.GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR = invalid;
_clearAnalyzerIdentityProcessCacheForTests();
expect(() => resolveAnalyzerRunnerIdentity(url)).toThrow(
/GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR/,
);
}
const linkedCache = path.join(fixture.dbPath, 'cache-link');
try {
await symlink(protectedCache.dbPath, linkedCache, 'dir');
process.env.GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR = linkedCache;
_clearAnalyzerIdentityProcessCacheForTests();
expect(() => resolveAnalyzerRunnerIdentity(url)).toThrow(/non-symlink|symbolic links/);
await unlink(linkedCache);
} catch (error) {
if (!['EPERM', 'EACCES'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
}
} finally {
if (previous === undefined) delete process.env.GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR;
else process.env.GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR = previous;
_clearAnalyzerIdentityProcessCacheForTests();
await protectedCache.cleanup();
await fixture.cleanup();
}
});
it('changes on lock and native/parser mutations while ignoring model caches', async () => {
const fixture = await createTempDir();
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
const grammarRoot = path.join(fixture.dbPath, 'vendor', 'tree-sitter-fixture');
const nativePath = path.join(
grammarRoot,
'prebuilds',
`${process.platform}-${process.arch}`,
'tree-sitter-fixture.node',
);
const sharedLibraryPath = path.join(
path.dirname(nativePath),
process.platform === 'win32'
? 'tree-sitter-fixture.dll'
: process.platform === 'darwin'
? 'libtree-sitter-fixture.dylib'
: 'libtree-sitter-fixture.so.1',
);
await mkdir(path.dirname(modulePath), { recursive: true });
await mkdir(path.dirname(nativePath), { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
'{"name":"fixture-analyzer","version":"9.8.7"}\n',
);
const originalLock = '{"name":"fixture-analyzer","lockfileVersion":3}\n';
await writeFile(path.join(fixture.dbPath, 'package-lock.json'), originalLock);
await writeFile(modulePath, 'export const analyzer = 1;\n');
await writeFile(
path.join(grammarRoot, 'package.json'),
'{"name":"tree-sitter-fixture","version":"1.0.0"}\n',
);
await writeFile(nativePath, 'native-v1');
await writeFile(sharedLibraryPath, 'shared-v1');
await writeFile(path.join(grammarRoot, 'tree-sitter-fixture.wasm'), 'wasm-v1');
const cacheDirectory = path.join(fixture.dbPath, 'identity-cache');
let hashedBytes = 0;
let runtimeArtifactHashes = 0;
const resolve = () =>
resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
onHashedInput: (input) => {
hashedBytes += input.bytes;
if (input.kind === 'runtime-artifact') runtimeArtifactHashes += 1;
},
});
const first = resolve();
expect(first.dependencyRuntime.artifactCount).toBe(3);
expect(runtimeArtifactHashes).toBe(3);
expect(hashedBytes).toBeGreaterThan(0);
expect(analyzerRunnerIdentitiesEqual(structuredClone(first), first)).toBe(true);
expect(analyzerRunnerIdentitiesEqual({ ...first, schemaVersion: 1 }, first)).toBe(false);
// The second resolver call models a fresh status/analyze process: the
// persistent cache is reloaded from disk, and unchanged payload bytes are
// never read even though both build/dependency inventories are validated.
hashedBytes = 0;
runtimeArtifactHashes = 0;
expect(resolve()).toEqual(first);
expect(runtimeArtifactHashes).toBe(0);
expect(hashedBytes).toBe(0);
await writeFile(
path.join(fixture.dbPath, 'package-lock.json'),
'{"name":"fixture-analyzer","lockfileVersion":4}\n',
);
const lockChanged = resolve();
expect(lockChanged.build.digest).toBe(first.build.digest);
expect(lockChanged.dependencyRuntime.digest).not.toBe(first.dependencyRuntime.digest);
expect(analyzerRunnerIdentitiesEqual(lockChanged, first)).toBe(false);
expect(runtimeArtifactHashes).toBe(0);
await writeFile(path.join(fixture.dbPath, 'package-lock.json'), originalLock);
await writeFile(nativePath, 'native-v2');
runtimeArtifactHashes = 0;
const nativeChanged = resolve();
expect(nativeChanged.dependencyRuntime.digest).not.toBe(first.dependencyRuntime.digest);
expect(runtimeArtifactHashes).toBe(1);
await writeFile(nativePath, 'native-v1');
await writeFile(sharedLibraryPath, 'shared-v2');
const sharedLibraryChanged = resolve();
expect(sharedLibraryChanged.dependencyRuntime.digest).not.toBe(
first.dependencyRuntime.digest,
);
const modelCache = path.join(fixture.dbPath, '.cache', 'models');
await mkdir(modelCache, { recursive: true });
await writeFile(path.join(modelCache, 'weights.bin'), 'large-model-placeholder');
runtimeArtifactHashes = 0;
const cacheChanged = resolve();
expect(cacheChanged.dependencyRuntime).toEqual(sharedLibraryChanged.dependencyRuntime);
expect(runtimeArtifactHashes).toBe(0);
// A corrupt cache is fail-closed: valid inventory stats cannot rescue
// unverifiable cached digests, so every expensive artifact is rehashed.
const [cacheFile] = await readdir(cacheDirectory);
await writeFile(path.join(cacheDirectory, cacheFile), '{"payload":{},"checksum":"bad"}\n');
_clearAnalyzerIdentityProcessCacheForTests();
runtimeArtifactHashes = 0;
hashedBytes = 0;
resolve();
expect(runtimeArtifactHashes).toBe(3);
expect(hashedBytes).toBeGreaterThan(0);
} finally {
await fixture.cleanup();
}
});
it('reuses the secure runtime cache across isolated HOME and GITNEXUS_HOME values', async () => {
const fixture = await createTempDir();
const previous = {
HOME: process.env.HOME,
GITNEXUS_HOME: process.env.GITNEXUS_HOME,
TMPDIR: process.env.TMPDIR,
XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
};
const restore = (name: keyof typeof previous): void => {
const value = previous[name];
if (value === undefined) delete process.env[name];
else process.env[name] = value;
};
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
const grammarRoot = path.join(fixture.dbPath, 'vendor', 'tree-sitter-fixture');
const nativePath = path.join(
grammarRoot,
'prebuilds',
`${process.platform}-${process.arch}`,
'tree-sitter-fixture.node',
);
const tempRoot = path.join(fixture.dbPath, 'runtime-cache-root');
await mkdir(path.dirname(modulePath), { recursive: true });
await mkdir(path.dirname(nativePath), { recursive: true });
await mkdir(tempRoot, { mode: 0o700 });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
'{"name":"fixture-analyzer","version":"9.8.7"}\n',
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
await writeFile(
path.join(grammarRoot, 'package.json'),
'{"name":"tree-sitter-fixture","version":"1.0.0"}\n',
);
await writeFile(nativePath, Buffer.alloc(2 * 1024 * 1024, 0x5a));
delete process.env.XDG_RUNTIME_DIR;
process.env.TMPDIR = tempRoot;
process.env.HOME = path.join(fixture.dbPath, 'home-a');
process.env.GITNEXUS_HOME = path.join(fixture.dbPath, 'gitnexus-home-a');
let runtimeHashes = 0;
let hashedBytes = 0;
const first = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
onHashedInput: (input) => {
if (input.kind === 'runtime-artifact') runtimeHashes += 1;
hashedBytes += input.bytes;
},
});
expect(runtimeHashes).toBe(1);
expect(hashedBytes).toBeGreaterThanOrEqual(2 * 1024 * 1024);
process.env.HOME = path.join(fixture.dbPath, 'home-b');
process.env.GITNEXUS_HOME = path.join(fixture.dbPath, 'gitnexus-home-b');
_clearAnalyzerIdentityProcessCacheForTests();
runtimeHashes = 0;
hashedBytes = 0;
let cacheMissWalks = 0;
let cacheMissReads = 0;
const second = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
onHashedInput: (input) => {
if (input.kind === 'runtime-artifact') runtimeHashes += 1;
hashedBytes += input.bytes;
},
onCacheMissWork: (input) => {
if (input.kind === 'directory-walk') cacheMissWalks += 1;
else cacheMissReads += 1;
},
});
expect(second).toEqual(first);
expect(runtimeHashes).toBe(0);
expect(hashedBytes).toBe(0);
expect(cacheMissWalks).toBe(0);
expect(cacheMissReads).toBe(0);
} finally {
restore('HOME');
restore('GITNEXUS_HOME');
restore('TMPDIR');
restore('XDG_RUNTIME_DIR');
await fixture.cleanup();
}
});
it('keeps warm identities stable when unrelated siblings churn in a shared parent', async () => {
const fixture = await createTempDir();
let unrelated: Awaited<ReturnType<typeof createTempDir>> | null = null;
try {
const modulePath = path.join(fixture.dbPath, 'src', 'core', 'analyzer.ts');
const cacheDirectory = path.join(fixture.dbPath, 'identity-cache');
await mkdir(path.dirname(modulePath), { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
'{"name":"fixture-analyzer","version":"9.8.7"}\n',
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
const first = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
unrelated = await createTempDir();
_clearAnalyzerIdentityProcessCacheForTests();
let cacheMissWork = 0;
const second = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
onCacheMissWork: () => {
cacheMissWork += 1;
},
});
expect(second).toEqual(first);
expect(cacheMissWork).toBe(0);
} finally {
if (unrelated) await unrelated.cleanup();
await fixture.cleanup();
}
});
it('invalidates an absent path guard when a nearer ancestor package lock appears', async () => {
const fixture = await createTempDir();
try {
const packageRoot = path.join(fixture.dbPath, 'packages', 'fixture-analyzer');
const modulePath = path.join(packageRoot, 'src', 'core', 'analyzer.ts');
const cacheDirectory = path.join(fixture.dbPath, 'identity-cache');
const ancestorLock = path.join(fixture.dbPath, 'package-lock.json');
await mkdir(path.dirname(modulePath), { recursive: true });
await writeFile(
path.join(packageRoot, 'package.json'),
'{"name":"fixture-analyzer","version":"9.8.7"}\n',
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
const withoutLock = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
expect(withoutLock.dependencyRuntime.lockfilePath).toBeNull();
await writeFile(ancestorLock, '{"lockfileVersion":3}\n');
const withLock = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
expect(withLock.dependencyRuntime.lockfilePath).toBe(ancestorLock);
expect(withLock.dependencyRuntime.digest).not.toBe(withoutLock.dependencyRuntime.digest);
} finally {
await fixture.cleanup();
}
});
it('invalidates an absent path guard when a nearer dependency shadows a hoisted one', async () => {
const fixture = await createTempDir();
try {
const packageRoot = path.join(fixture.dbPath, 'packages', 'fixture-analyzer');
const modulePath = path.join(packageRoot, 'src', 'core', 'analyzer.ts');
const hoistedRoot = path.join(fixture.dbPath, 'node_modules', 'runtime-package');
const nearerRoot = path.join(fixture.dbPath, 'packages', 'node_modules', 'runtime-package');
const cacheDirectory = path.join(fixture.dbPath, 'identity-cache');
await mkdir(path.dirname(modulePath), { recursive: true });
await mkdir(hoistedRoot, { recursive: true });
await writeFile(
path.join(packageRoot, 'package.json'),
JSON.stringify({
name: 'fixture-analyzer',
version: '9.8.7',
dependencies: { 'runtime-package': '1.0.0' },
}),
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
await writeFile(
path.join(hoistedRoot, 'package.json'),
JSON.stringify({ name: 'runtime-package', version: '1.0.0' }),
);
await writeFile(path.join(hoistedRoot, 'runtime.js'), 'export const source = "hoisted";\n');
const hoisted = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
await mkdir(nearerRoot, { recursive: true });
await writeFile(
path.join(nearerRoot, 'package.json'),
JSON.stringify({ name: 'runtime-package', version: '2.0.0' }),
);
await writeFile(path.join(nearerRoot, 'runtime.js'), 'export const source = "nearer";\n');
const shadowed = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
expect(shadowed.dependencyRuntime.digest).not.toBe(hoisted.dependencyRuntime.digest);
} finally {
await fixture.cleanup();
}
});
it('invalidates a warm identity when an intermediate package symlink is retargeted', async () => {
const fixture = await createTempDir();
try {
const packageRoot = path.join(fixture.dbPath, 'fixture-analyzer');
const modulePath = path.join(packageRoot, 'src', 'core', 'analyzer.ts');
const nodeModulesRoot = path.join(packageRoot, 'node_modules');
const packageLink = path.join(nodeModulesRoot, 'runtime-package');
const storeA = path.join(fixture.dbPath, 'store-a');
const storeB = path.join(fixture.dbPath, 'store-b');
const cacheDirectory = path.join(fixture.dbPath, 'identity-cache');
await mkdir(path.dirname(modulePath), { recursive: true });
await mkdir(nodeModulesRoot, { recursive: true });
await mkdir(storeA);
await mkdir(storeB);
await writeFile(
path.join(packageRoot, 'package.json'),
JSON.stringify({
name: 'fixture-analyzer',
version: '9.8.7',
dependencies: { 'runtime-package': '1.0.0' },
}),
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
await writeFile(
path.join(storeA, 'package.json'),
JSON.stringify({ name: 'runtime-package', version: '1.0.0' }),
);
// Keep the final candidate manifest's inode and stat state identical so
// only an exact lexical-component guard can observe the retarget.
await link(path.join(storeA, 'package.json'), path.join(storeB, 'package.json'));
await writeFile(path.join(storeA, 'runtime.js'), 'export const source = "a";\n');
await writeFile(path.join(storeB, 'runtime.js'), 'export const source = "b changed";\n');
try {
await symlink(storeA, packageLink, 'dir');
} catch (error) {
if (['EPERM', 'EACCES'].includes((error as NodeJS.ErrnoException).code ?? '')) return;
throw error;
}
const first = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
await unlink(packageLink);
await symlink(storeB, packageLink, 'dir');
_clearAnalyzerIdentityProcessCacheForTests();
let cacheMissWork = 0;
const retargeted = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
onCacheMissWork: () => {
cacheMissWork += 1;
},
});
expect(retargeted.dependencyRuntime.digest).not.toBe(first.dependencyRuntime.digest);
expect(cacheMissWork).toBeGreaterThan(0);
} finally {
await fixture.cleanup();
}
});
it('invalidates direct-stat guards for build, topology, and artifact inventory changes', async () => {
const fixture = await createTempDir();
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
const grammarRoot = path.join(fixture.dbPath, 'vendor', 'tree-sitter-fixture');
const artifactDir = path.join(
grammarRoot,
'prebuilds',
`${process.platform}-${process.arch}`,
);
const nativePath = path.join(artifactDir, 'tree-sitter-fixture.node');
const addedNativePath = path.join(artifactDir, 'tree-sitter-extra.node');
const manifestPath = path.join(grammarRoot, 'package.json');
const cacheDirectory = path.join(fixture.dbPath, 'identity-cache');
await mkdir(path.dirname(modulePath), { recursive: true });
await mkdir(artifactDir, { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
'{"name":"fixture-analyzer","version":"9.8.7"}\n',
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
await writeFile(manifestPath, '{"name":"tree-sitter-fixture","version":"1.0.0"}\n');
await writeFile(nativePath, 'native-v1');
const first = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
expect(first.dependencyRuntime.artifactCount).toBe(1);
let walks = 0;
let reads = 0;
let hashes = 0;
const resolve = () =>
resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
onCacheMissWork: (input) => {
if (input.kind === 'directory-walk') walks += 1;
else reads += 1;
},
onHashedInput: () => {
hashes += 1;
},
});
const reset = () => {
walks = 0;
reads = 0;
hashes = 0;
};
expect(resolve()).toEqual(first);
expect({ walks, reads, hashes }).toEqual({ walks: 0, reads: 0, hashes: 0 });
await writeFile(path.join(sourceRoot, 'added.ts'), 'export const added = true;\n');
reset();
const buildAdded = resolve();
expect(buildAdded.build.digest).not.toBe(first.build.digest);
expect(walks).toBeGreaterThan(0);
await writeFile(addedNativePath, 'native-extra');
reset();
const artifactAdded = resolve();
expect(artifactAdded.dependencyRuntime.artifactCount).toBe(2);
expect(artifactAdded.dependencyRuntime.digest).not.toBe(buildAdded.dependencyRuntime.digest);
expect(walks).toBeGreaterThan(0);
expect(hashes).toBe(1);
await writeFile(manifestPath, '{"name":"tree-sitter-fixture","version":"2.0.0"}\n');
reset();
const manifestChanged = resolve();
expect(manifestChanged.dependencyRuntime.digest).not.toBe(
artifactAdded.dependencyRuntime.digest,
);
expect(reads).toBeGreaterThan(0);
await unlink(nativePath);
reset();
const artifactDeleted = resolve();
expect(artifactDeleted.dependencyRuntime.artifactCount).toBe(1);
expect(artifactDeleted.dependencyRuntime.digest).not.toBe(
manifestChanged.dependencyRuntime.digest,
);
expect(walks).toBeGreaterThan(0);
} finally {
await fixture.cleanup();
}
});
it('keeps same-name/version dependency instances distinct by package-root locator', async () => {
const fixture = await createTempDir();
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
const nestedA = path.join(
fixture.dbPath,
'node_modules',
'parent-a',
'node_modules',
'duplicate',
);
const nestedB = path.join(
fixture.dbPath,
'node_modules',
'parent-b',
'node_modules',
'duplicate',
);
const cacheDirectory = path.join(fixture.dbPath, 'identity-cache');
await mkdir(path.dirname(modulePath), { recursive: true });
await mkdir(nestedA, { recursive: true });
await mkdir(nestedB, { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
JSON.stringify({
name: 'fixture-analyzer',
version: '9.8.7',
dependencies: { 'parent-a': '1.0.0', 'parent-b': '1.0.0' },
}),
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
for (const parentName of ['parent-a', 'parent-b']) {
await writeFile(
path.join(fixture.dbPath, 'node_modules', parentName, 'package.json'),
JSON.stringify({
name: parentName,
version: '1.0.0',
dependencies: { duplicate: '1.0.0' },
}),
);
}
for (const nestedRoot of [nestedA, nestedB]) {
await writeFile(
path.join(nestedRoot, 'package.json'),
JSON.stringify({ name: 'duplicate', version: '1.0.0' }),
);
await writeFile(path.join(nestedRoot, 'runtime.wasm'), 'same-runtime-bytes');
}
const first = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
expect(first.dependencyRuntime.packageCount).toBe(5);
expect(first.dependencyRuntime.artifactCount).toBe(2);
const [cacheFile] = await readdir(cacheDirectory);
const envelope = JSON.parse(await readFile(path.join(cacheDirectory, cacheFile), 'utf8')) as {
payload: { artifactEntries: Array<{ canonicalPath: string }> };
};
const artifactLocators = envelope.payload.artifactEntries.map((entry) => entry.canonicalPath);
expect(artifactLocators).toEqual(
expect.arrayContaining([
expect.stringContaining('node_modules/parent-a/node_modules/duplicate/runtime.wasm'),
expect.stringContaining('node_modules/parent-b/node_modules/duplicate/runtime.wasm'),
]),
);
expect(new Set(artifactLocators).size).toBe(2);
await writeFile(
path.join(nestedB, 'package.json'),
JSON.stringify({ name: 'duplicate', version: '1.0.0', instance: 'parent-b' }),
);
const changedOneInstance = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
expect(changedOneInstance.dependencyRuntime.packageCount).toBe(5);
expect(changedOneInstance.dependencyRuntime.digest).not.toBe(first.dependencyRuntime.digest);
} finally {
await fixture.cleanup();
}
});
it('discovers runtime artifacts in every resolved package without an allowlist', async () => {
const fixture = await createTempDir();
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
const dependencyRoot = path.join(fixture.dbPath, 'node_modules', 'ordinary-runtime');
const nativePath = path.join(dependencyRoot, 'build', 'addon.node');
const wasmPath = path.join(dependencyRoot, 'codec', 'runtime.wasm');
const cacheDirectory = path.join(fixture.dbPath, 'identity-cache');
await mkdir(path.dirname(modulePath), { recursive: true });
await mkdir(path.dirname(nativePath), { recursive: true });
await mkdir(path.dirname(wasmPath), { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
JSON.stringify({
name: 'fixture-analyzer',
version: '9.8.7',
dependencies: { 'ordinary-runtime': '1.0.0' },
}),
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
await writeFile(
path.join(dependencyRoot, 'package.json'),
JSON.stringify({ name: 'ordinary-runtime', version: '1.0.0' }),
);
await writeFile(nativePath, 'native-v1');
await writeFile(wasmPath, 'wasm-v1');
const first = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
expect(first.dependencyRuntime).toMatchObject({ packageCount: 2, artifactCount: 2 });
await writeFile(nativePath, 'native-v2-with-a-different-size');
const changed = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
expect(changed.dependencyRuntime.digest).not.toBe(first.dependencyRuntime.digest);
} finally {
await fixture.cleanup();
}
});
it('tracks generic runtime directories and filename-mismatched native payloads on cold and warm scans', async () => {
const fixture = await createTempDir();
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
const dependencyRoot = path.join(fixture.dbPath, 'node_modules', 'runtime-package');
const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux';
const foreignArchitecture = process.arch === 'x64' ? 'arm64' : 'x64';
const payloadPaths = [
path.join(dependencyRoot, '.cache', 'generated-loader.js'),
path.join(
dependencyRoot,
'cache',
`${foreignPlatform}-${foreignArchitecture}`,
'addon.node',
),
path.join(dependencyRoot, 'models', 'runtime-model.wasm'),
path.join(
dependencyRoot,
'prebuilds',
`${foreignPlatform}-${foreignArchitecture}`,
'foreign-target.node',
),
path.join(
dependencyRoot,
'codec',
`runtime-${foreignPlatform}-${foreignArchitecture}.wasm`,
),
];
await mkdir(path.dirname(modulePath), { recursive: true });
for (const payloadPath of payloadPaths) {
await mkdir(path.dirname(payloadPath), { recursive: true });
}
await mkdir(path.join(dependencyRoot, '.git'), { recursive: true });
await mkdir(path.join(dependencyRoot, '.hg'), { recursive: true });
await mkdir(path.join(dependencyRoot, '.svn'), { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
JSON.stringify({
name: 'fixture-analyzer',
version: '9.8.7',
dependencies: { 'runtime-package': '1.0.0' },
}),
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
await writeFile(
path.join(dependencyRoot, 'package.json'),
JSON.stringify({ name: 'runtime-package', version: '1.0.0' }),
);
await writeFile(path.join(dependencyRoot, '.git', 'config'), 'ignored-vcs-state');
await writeFile(path.join(dependencyRoot, '.hg', 'dirstate'), 'ignored-vcs-state');
await writeFile(path.join(dependencyRoot, '.svn', 'wc.db'), 'ignored-vcs-state');
for (const payloadPath of payloadPaths) await writeFile(payloadPath, 'payload-v1');
const baseline = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory: path.join(fixture.dbPath, 'baseline-cache'),
});
expect(baseline.dependencyRuntime.artifactCount).toBe(payloadPaths.length);
for (const payloadPath of payloadPaths) {
await writeFile(payloadPath, `payload-v2:${path.basename(payloadPath)}`);
}
const warmCacheDirectory = path.join(fixture.dbPath, 'warm-cache');
let runtimeHashes = 0;
const coldAfterMutation = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory: warmCacheDirectory,
onHashedInput: (input) => {
if (input.kind === 'runtime-artifact') runtimeHashes += 1;
},
});
expect(coldAfterMutation.dependencyRuntime.digest).not.toBe(
baseline.dependencyRuntime.digest,
);
expect(runtimeHashes).toBe(payloadPaths.length);
runtimeHashes = 0;
expect(
resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory: warmCacheDirectory,
onHashedInput: (input) => {
if (input.kind === 'runtime-artifact') runtimeHashes += 1;
},
}),
).toEqual(coldAfterMutation);
expect(runtimeHashes).toBe(0);
for (const payloadPath of payloadPaths) {
await writeFile(payloadPath, `payload-v3-with-new-bytes:${path.basename(payloadPath)}`);
}
runtimeHashes = 0;
const warmAfterMutation = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory: warmCacheDirectory,
onHashedInput: (input) => {
if (input.kind === 'runtime-artifact') runtimeHashes += 1;
},
});
expect(warmAfterMutation.dependencyRuntime.digest).not.toBe(
coldAfterMutation.dependencyRuntime.digest,
);
expect(runtimeHashes).toBe(payloadPaths.length);
} finally {
await fixture.cleanup();
}
});
it('fails closed when a resolved-package artifact walk exceeds its depth bound', async () => {
const fixture = await createTempDir();
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
const dependencyRoot = path.join(fixture.dbPath, 'node_modules', 'deep-runtime');
await mkdir(path.dirname(modulePath), { recursive: true });
await mkdir(dependencyRoot, { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
JSON.stringify({
name: 'fixture-analyzer',
version: '9.8.7',
dependencies: { 'deep-runtime': '1.0.0' },
}),
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
await writeFile(
path.join(dependencyRoot, 'package.json'),
JSON.stringify({ name: 'deep-runtime', version: '1.0.0' }),
);
let cursor = dependencyRoot;
for (let depth = 0; depth < 66; depth += 1) {
cursor = path.join(cursor, 'd');
await mkdir(cursor);
}
expect(() =>
resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory: path.join(fixture.dbPath, 'identity-cache'),
}),
).toThrow(/payload scan exceeded depth 64/);
} finally {
await fixture.cleanup();
}
});
it('stable-reads symlinked package locks and rejects broken lock links', async () => {
const fixture = await createTempDir();
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
const lockTarget = path.join(fixture.dbPath, 'actual-package-lock.json');
const lockLink = path.join(fixture.dbPath, 'package-lock.json');
const cacheDirectory = path.join(fixture.dbPath, 'identity-cache');
await mkdir(path.dirname(modulePath), { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
'{"name":"fixture-analyzer","version":"9.8.7"}\n',
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
await writeFile(lockTarget, '{"lockfileVersion":3}\n');
try {
await symlink(lockTarget, lockLink, 'file');
} catch (error) {
if (['EPERM', 'EACCES'].includes((error as NodeJS.ErrnoException).code ?? '')) return;
throw error;
}
const first = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
expect(first.dependencyRuntime.lockfilePath).toBe(lockLink);
await writeFile(lockTarget, '{"lockfileVersion":4,"changed":true}\n');
const targetChanged = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
expect(targetChanged.dependencyRuntime.digest).not.toBe(first.dependencyRuntime.digest);
await unlink(lockLink);
await symlink(path.join(fixture.dbPath, 'missing-lock-target.json'), lockLink, 'file');
expect(() =>
resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, { cacheDirectory }),
).toThrow(/package lock symbolic link does not resolve to a file/);
} finally {
await fixture.cleanup();
}
});
it('uses one final warm validation pass and notices immediate file and topology changes', async () => {
const fixture = await createTempDir();
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
const addedPath = path.join(sourceRoot, 'added-at-boundary.ts');
const cacheDirectory = path.join(fixture.dbPath, 'identity-cache');
await mkdir(path.dirname(modulePath), { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
'{"name":"fixture-analyzer","version":"9.8.7"}\n',
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
const first = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
let validationPasses = 0;
expect(
resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
onCacheValidationPass: () => {
validationPasses += 1;
},
}),
).toEqual(first);
expect(validationPasses).toBe(1);
validationPasses = 0;
let changedFile = false;
const fileChanged = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
onCacheValidationPass: () => {
validationPasses += 1;
if (!changedFile) {
changedFile = true;
writeFileSync(modulePath, 'export const analyzer = 200;\n');
}
},
});
expect(fileChanged.build.digest).not.toBe(first.build.digest);
expect(validationPasses).toBe(2);
validationPasses = 0;
let changedTopology = false;
const topologyChanged = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
onCacheValidationPass: () => {
validationPasses += 1;
if (!changedTopology) {
changedTopology = true;
writeFileSync(addedPath, 'export const added = true;\n');
}
},
});
expect(topologyChanged.build.digest).not.toBe(fileChanged.build.digest);
expect(validationPasses).toBe(2);
} finally {
await fixture.cleanup();
}
});
it('keeps warm-cache work at zero and materially below cold-path latency', async () => {
const fixture = await createTempDir();
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
const payloadPath = path.join(sourceRoot, 'large-runtime-source.bin');
const cacheDirectory = path.join(fixture.dbPath, 'identity-cache');
const payloadBytes = 32 * 1024 * 1024;
await mkdir(path.dirname(modulePath), { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
'{"name":"fixture-analyzer","version":"9.8.7"}\n',
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
await writeFile(payloadPath, Buffer.alloc(payloadBytes, 0x61));
let coldHashedBytes = 0;
let coldTopologyWork = 0;
let coldValidationPasses = 0;
const coldStarted = performance.now();
const coldIdentity = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
onHashedInput: ({ bytes }) => {
coldHashedBytes += bytes;
},
onCacheMissWork: () => {
coldTopologyWork += 1;
},
onCacheValidationPass: () => {
coldValidationPasses += 1;
},
});
const coldDurationMs = performance.now() - coldStarted;
expect(coldHashedBytes).toBeGreaterThanOrEqual(payloadBytes);
expect(coldTopologyWork).toBeGreaterThan(0);
expect(coldValidationPasses).toBe(1);
const warmDurationsMs: number[] = [];
for (let iteration = 0; iteration < 5; iteration += 1) {
let warmHashedBytes = 0;
let warmTopologyWork = 0;
let warmValidationPasses = 0;
const warmStarted = performance.now();
const warmIdentity = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
onHashedInput: ({ bytes }) => {
warmHashedBytes += bytes;
},
onCacheMissWork: () => {
warmTopologyWork += 1;
},
onCacheValidationPass: () => {
warmValidationPasses += 1;
},
});
warmDurationsMs.push(performance.now() - warmStarted);
expect(warmIdentity).toEqual(coldIdentity);
expect(warmHashedBytes).toBe(0);
expect(warmTopologyWork).toBe(0);
expect(warmValidationPasses).toBe(1);
}
warmDurationsMs.sort((a, b) => a - b);
const medianWarmDurationMs = warmDurationsMs[Math.floor(warmDurationsMs.length / 2)];
expect(medianWarmDurationMs).toBeLessThan(coldDurationMs);
} finally {
await fixture.cleanup();
}
});
it('uses non-ambiguous framing and treats the invoked entrypoint as diagnostic', async () => {
const leftOldEncoding = Buffer.concat([
Buffer.from('a'),
Buffer.from([0]),
Buffer.from('b\0c'),
Buffer.from([0]),
]);
const rightOldEncoding = Buffer.concat([
Buffer.from('a\0b'),
Buffer.from([0]),
Buffer.from('c'),
Buffer.from([0]),
]);
expect(leftOldEncoding).toEqual(rightOldEncoding);
expect(_hashAnalyzerIdentityFramesForTests([['entry', 'a', 'b\0c']])).not.toBe(
_hashAnalyzerIdentityFramesForTests([['entry', 'a\0b', 'c']]),
);
const fixture = await createTempDir();
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
await mkdir(path.dirname(modulePath), { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
'{"name":"fixture-analyzer","version":"9.8.7"}\n',
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
const options = { cacheDirectory: path.join(fixture.dbPath, 'identity-cache') };
const identity = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, options);
const alternateEntrypoint = {
...identity,
invokedArtifact: {
path: path.join(sourceRoot, 'server', 'analyze-worker.ts'),
digest: `sha256:${'a'.repeat(64)}`,
},
};
expect(analyzerRunnerIdentitiesEqual(alternateEntrypoint, identity)).toBe(true);
expect(normalizeAnalyzerRunnerIdentityForComparison(alternateEntrypoint)).toEqual(
normalizeAnalyzerRunnerIdentityForComparison(identity),
);
expect(normalizeAnalyzerRunnerIdentityForComparison({ schemaVersion: 4 })).toBeNull();
expect(
analyzerRunnerIdentitiesEqual(
{ ...alternateEntrypoint, invokedArtifact: { path: '', digest: 'bad' } },
identity,
),
).toBe(false);
// Fail-closed on a receipt that cannot be read at all — the same posture as
// an absent schemaFingerprint. An index predating the field stamps nothing
// (undefined) and a cleared/legacy field reads back as null; neither is ever
// grandfathered into an incremental top-up (#2798).
expect(analyzerRunnerIdentitiesEqual(undefined, identity)).toBe(false);
expect(analyzerRunnerIdentitiesEqual(null, identity)).toBe(false);
await writeFile(
path.join(sourceRoot, 'new-semantic-input.ts'),
'export const changed = 1;\n',
);
expect(() =>
finalizeAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, identity, options),
).toThrow(/changed during analysis/);
} finally {
await fixture.cleanup();
}
});
it('captures before loading and rejects a replacement that races module evaluation', async () => {
const fixture = await createTempDir();
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
await mkdir(path.dirname(modulePath), { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
'{"name":"fixture-analyzer","version":"9.8.7"}\n',
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
const options = { cacheDirectory: path.join(fixture.dbPath, 'identity-cache') };
const prepared = await captureAnalyzerIdentityBeforeLoad(
pathToFileURL(modulePath).href,
async () => {
// Change the size as well as the bytes so filesystems with coarse
// timestamp granularity cannot make this race regression flaky.
await writeFile(modulePath, 'export const analyzer = 200;\n');
return 'loaded-after-replacement';
},
options,
);
expect(prepared.loaded).toBe('loaded-after-replacement');
expect(() =>
finalizeAnalyzerRunnerIdentity(
pathToFileURL(modulePath).href,
prepared.runnerIdentity,
options,
),
).toThrow(/changed during analysis/);
} finally {
await fixture.cleanup();
}
});
it('content-addresses every resolved package payload and reuses it without byte reads', async () => {
const fixture = await createTempDir();
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
const dependencyRoot = path.join(fixture.dbPath, 'node_modules', 'runtime-package');
await mkdir(path.dirname(modulePath), { recursive: true });
await mkdir(dependencyRoot, { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
JSON.stringify({
name: 'fixture-analyzer',
version: '9.8.7',
dependencies: { 'runtime-package': '1.0.0' },
}),
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
await writeFile(
path.join(dependencyRoot, 'package.json'),
JSON.stringify({ name: 'runtime-package', version: '1.0.0' }),
);
const payloadNames = [
'index.js',
'legacy.cjs',
'module.mjs',
'data.json',
'addon.node',
'runtime.wasm',
'extensionless',
'runtime-config.txt',
];
for (const name of payloadNames)
await writeFile(path.join(dependencyRoot, name), `${name}:v1`);
const cacheDirectory = path.join(fixture.dbPath, 'identity-cache');
const first = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
expect(first.dependencyRuntime.artifactCount).toBe(payloadNames.length);
let warmBytes = 0;
expect(
resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
onHashedInput: ({ bytes }) => {
warmBytes += bytes;
},
}),
).toEqual(first);
expect(warmBytes).toBe(0);
let priorDigest = first.dependencyRuntime.digest;
for (const name of payloadNames) {
await writeFile(path.join(dependencyRoot, name), `${name}:v2-with-new-bytes`);
const changed = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory,
});
expect(changed.dependencyRuntime.digest).not.toBe(priorDigest);
priorDigest = changed.dependencyRuntime.digest;
}
} finally {
await fixture.cleanup();
}
});
it('enforces iterative build, package, edge, entry, payload, byte, depth, and resolution bounds', async () => {
const fixture = await createTempDir();
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
const dependencyRoot = path.join(fixture.dbPath, 'node_modules', 'runtime-package');
await mkdir(path.dirname(modulePath), { recursive: true });
await mkdir(path.join(dependencyRoot, 'deep', 'deeper'), { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
JSON.stringify({
name: 'fixture-analyzer',
version: '9.8.7',
dependencies: { 'runtime-package': '1.0.0', missing: '1.0.0' },
}),
);
await writeFile(modulePath, 'export const analyzer = 1;\n');
await writeFile(path.join(sourceRoot, 'extra.ts'), 'export const extra = true;\n');
await mkdir(path.join(sourceRoot, 'nested', 'deeper'), { recursive: true });
await writeFile(path.join(sourceRoot, 'nested', 'deeper', 'leaf.ts'), 'export {};\n');
await writeFile(
path.join(dependencyRoot, 'package.json'),
JSON.stringify({ name: 'runtime-package', version: '1.0.0' }),
);
await writeFile(path.join(dependencyRoot, 'a.js'), 'a');
await writeFile(path.join(dependencyRoot, 'b.json'), '{}');
await writeFile(path.join(dependencyRoot, 'deep', 'deeper', 'c.mjs'), 'c');
const url = pathToFileURL(modulePath).href;
let sequence = 0;
const bounded = (traversalLimits: Record<string, number>) => () =>
resolveAnalyzerRunnerIdentity(url, {
cacheDirectory: path.join(fixture.dbPath, `cache-${sequence++}`),
traversalLimits,
});
expect(bounded({ buildEntries: 1 })).toThrow(/build scan exceeded 1 entries/);
expect(bounded({ buildDepth: 1 })).toThrow(/build scan exceeded depth 1/);
expect(bounded({ buildBytes: 1 })).toThrow(/build scan exceeded 1 bytes/);
expect(bounded({ runtimePackages: 1 })).toThrow(/dependency graph exceeded 1 packages/);
expect(bounded({ runtimeEdges: 1 })).toThrow(/dependency graph exceeded 1 edges/);
expect(bounded({ runtimeEntries: 1 })).toThrow(/payload scan exceeded 1 entries/);
expect(bounded({ runtimeDepth: 1 })).toThrow(/payload scan exceeded depth 1/);
expect(bounded({ runtimePayloads: 1 })).toThrow(/payload scan exceeded 1 payloads/);
expect(bounded({ runtimeBytes: 1 })).toThrow(/runtime scan exceeded 1 bytes/);
expect(bounded({ resolutionAncestors: 1 })).toThrow(/exceeded 1 ancestors/);
} finally {
await fixture.cleanup();
}
});
it('persists the same receipt to both metadata mirrors on full and incremental runs', async () => {
const repo = await setupMiniRepo();
try {
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true, skipSkills: true },
{ onProgress: () => {} },
);
const { storagePath } = getStoragePaths(repo.dbPath);
const first = await loadMeta(storagePath);
const expectedIdentity = resolveAnalyzerRunnerIdentity(
pathToFileURL(path.resolve(__dirname, '../../src/core/run-analyze.ts')).href,
);
expect(first?.runnerIdentity).toEqual(expectedIdentity);
expect(first?.runnerIdentity).toMatchObject({
schemaVersion: 4,
cliVersion: expect.any(String),
invokedArtifact: { digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/) },
build: { digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/) },
dependencyRuntime: { digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/) },
});
if (!first?.runnerIdentity) throw new Error('analysis did not persist a runner identity');
const legacyMeta = {
...first,
runnerIdentity: { ...first.runnerIdentity, schemaVersion: 1 },
} as unknown as RepoMeta;
await saveMeta(storagePath, legacyMeta);
const upgraded = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true, skipSkills: true },
{ onProgress: () => {} },
);
expect(upgraded.alreadyUpToDate).toBeUndefined();
expect((await loadMeta(storagePath))?.runnerIdentity).toEqual(first.runnerIdentity);
const changedPath = path.join(repo.dbPath, 'src', 'logger.ts');
const before = await readFile(changedPath, 'utf8');
await writeFile(changedPath, `${before}\n// force incremental identity restamp\n`, 'utf8');
const incremental = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true, skipSkills: true },
{ onProgress: () => {} },
);
expect(incremental.alreadyUpToDate).toBeUndefined();
const second = await loadMeta(storagePath);
expect(second?.runnerIdentity).toEqual(first?.runnerIdentity);
const primary = JSON.parse(
await readFile(path.join(storagePath, 'gitnexus.json'), 'utf8'),
) as { runnerIdentity?: unknown };
const legacy = JSON.parse(await readFile(path.join(storagePath, 'meta.json'), 'utf8')) as {
runnerIdentity?: unknown;
};
expect(primary.runnerIdentity).toEqual(second?.runnerIdentity);
expect(legacy.runnerIdentity).toEqual(second?.runnerIdentity);
} finally {
await repo.cleanup();
}
}, 300_000);
});
// #2668 threading guard: the produced identity's path fields must already be
// normalizer-stable, i.e. resolveBuildRoot/resolveRuntimeVariant actually route
// build.rootPath and runtime.executablePath through normalizeAnalyzerRootPath.
// Ubuntu-only by necessity: this needs a real fixture identity, and the fixture
// harness cannot run on the Windows matrix (the runner's repo is on D: while temp
// is on C:, and isInside() misjudges cross-drive paths so resolveInvokedArtifact
// picks the vitest fork worker). The pure-transform assertions that DO run on
// windows-latest live in analyzer-identity-path-normalization.test.ts.
describe('analyzer identity path threading (#2668)', () => {
it('produces identity path fields that are already normalizer-stable', async () => {
const fixture = await createTempDir();
try {
const sourceRoot = path.join(fixture.dbPath, 'src');
const modulePath = path.join(sourceRoot, 'core', 'analyzer.ts');
await mkdir(path.dirname(modulePath), { recursive: true });
await writeFile(
path.join(fixture.dbPath, 'package.json'),
'{"name":"fixture-analyzer","version":"1.0.0"}\n',
);
await writeFile(path.join(fixture.dbPath, 'package-lock.json'), '{"lockfileVersion":3}\n');
await writeFile(modulePath, 'export const analyzer = 1;\n');
const identity = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
cacheDirectory: path.join(fixture.dbPath, 'identity-cache'),
});
expect(identity.build.rootPath).toBe(
normalizeAnalyzerRootPath(identity.build.rootPath, process.platform),
);
expect(identity.runtime.executablePath).toBe(
normalizeAnalyzerRootPath(identity.runtime.executablePath, process.platform),
);
} finally {
await fixture.cleanup();
}
});
});