mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
* 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>
1565 lines
64 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|