GitNexus/gitnexus/test/unit/tool-staleness.test.ts
Gergő Magyar df0110b06f
fix: index staleness — false-stale status after analyze (#2668) + inline staleness in query/context/impact/cypher tools (#2655) (#2683)
* fix(analyzer): case-stabilize runner-identity path fields so status isn't false-stale (#2668)

`gitnexus status` reported a freshly-analyzed, untouched repo as stale on
Windows (econia/aptos-core, 1.6.10-aptos.0). `status`'s up-to-date check gates
on `runnerIdentityIsCurrent`, which deep-compares the stamped runner identity
against a freshly recomputed one. That comparison includes `build.rootPath`,
`dependencyRuntime.manifestPath`/`lockfilePath`, and `runtime.executablePath`
(only `invokedArtifact` is stripped), and `identityCacheKey` hashes
packageRoot/buildRoot — all derived from paths that flow through
`realpathSync.native`, which canonicalizes 8.3 names and symlinks but does NOT
normalize the Windows drive-letter case. When `analyze` and `status` are
launched under different drive-letter casing (`c:\...` vs `C:\...`, plausible
across CLI shim / npx / server-worker entries), the two identities differ by
that one byte and `status` reports stale.

Fix: `normalizeAnalyzerRootPath(p, platform)` uppercases the Windows drive
letter (POSIX no-op, platform-explicit for testability; preserves a `\\?\`
extended-length prefix), applied at the single upstream source —
`resolveBuildRoot`'s returned `{packageRoot, buildRoot}` — so every derived
identity path field and the cache key inherit a case-stable root, plus at
`runtime.executablePath` (process.execPath is the same compared class). The
`runnerIdentityIsCurrent` gate is kept intact: a genuine analyzer change still
differs in `build.digest`/`dependencyRuntime`, and analyze still rebuilds on
real mismatch.

Note: the drive-letter divergence was not reproduced on a Windows host (none
available); the mechanical chain is verified in source and the fix is a correct
defensive normalization that is a no-op on POSIX. If a `status --json` identity
field-diff later shows `build.digest`/`dependencyRuntime`/`cliVersion`
diverging instead, that indicates a genuinely different install (where "stale"
is correct), not this bug.

Migration: on Windows, an existing index stamped under the old (non-normalized)
casing mismatches the normalized recompute once, triggering a single forced
full re-analyze on first upgrade (and a one-time identity-cache recompute).
One-time, Windows-only, POSIX no-op.

Tests: pure `normalizeAnalyzerRootPath` unit tests (drive-letter uppercase,
idempotence, drive-only scope, `\\?\` extended-length prefix, POSIX no-op).

* feat(mcp): surface index staleness in query/context/impact/cypher tool responses (#2655)

`checkStalenessAsync` already computes how many commits an index is behind the
checkout's HEAD, and `list_repos` returns it as `staleness: {commitsBehind,
hint}`. But the four hot read tools an agent actually calls in a session —
`query`, `context`, `impact`, `cypher` — never surfaced it: `resolveRepo` only
runs `maybeWarnSiblingDrift` (stderr, sibling-clone drift only), so a direct
tool call gave zero indication the index might be behind HEAD.

Thread the existing signal into those four tools at the single `callTool`
dispatch chokepoint (after the one `resolveRepo`), reusing the `list_repos`
`{commitsBehind, hint}` shape:

- `stalenessForTool` computes `checkStalenessAsync` behind an in-flight-promise
  cache (5s TTL) keyed by lbugPath, so N concurrent tool calls share one
  `git rev-list` and flat/branch handles (same repoPath, different lastCommit)
  don't collide. The cache entry is evicted with the repo's other per-index
  state when the repo leaves the registry.
- `withToolStaleness` skips the `git` spawn entirely for results that can't
  carry the field (via `canCarryStaleness`), so error-returning calls pay
  nothing.
- `attachToolStaleness` adds a `staleness` field to an object result only when
  the index is behind HEAD. It NEVER changes an existing result's shape:
  raw-array results (non-tabular cypher rows) are returned untouched, because
  the CLI's `--limit` and other consumers branch on `Array.isArray`; error
  envelopes and already-annotated results are left as-is. Non-blocking:
  `checkStalenessAsync` swallows git failures to `{isStale:false}`, so a git
  error just omits the field — it never fails the tool.

Deliberately out of scope: `@group`-targeted calls forward to
`callToolAtGroupRepo` before the chokepoint (multi-repo, single-commit
staleness is ill-defined); the legacy `search`/`explore` aliases; and
`list_repos` / the `context` resource, which already carry the signal.

Tests: `attachToolStaleness` branch matrix (stale object -> field; fresh ->
unchanged; raw array -> unchanged; error envelope -> unchanged; idempotent;
non-object -> unchanged; null-safe) and a flat-vs-branch cache-key regression
test that fails when the cache is keyed by repoPath.

* test(mcp): cover staleness tool-signal edge cases + harden the freshness boundary (#2655)

Addresses the coverage gaps the review flagged on the #2655 staleness signal,
plus one defensive guard so a failing freshness check can never fail a tool.

Production (defense-in-depth, no behavior change on the happy path):
- withToolStaleness now awaits stalenessForTool with a `.catch(() => undefined)`
  so a rejection degrades to no-staleness instead of failing query/cypher/
  context/impact.
- stalenessForTool wraps the check in `Promise.resolve(...).catch(...)` that
  evicts the cache entry on rejection — a transient failure isn't served as a
  permanently-rejecting promise for the rest of the TTL window, and the
  `Promise.resolve` wrap makes the boundary robust to a non-thenable return
  (a no-op for the real async checkStalenessAsync). A resolving promise is
  never evicted, so happy-path dedup is unchanged.

Tests (gitnexus/test/unit/calltool-dispatch.test.ts):
- F1: a rejecting checkStalenessAsync leaves the tool payload intact with no
  staleness field, and a later call recovers (proves the entry isn't poisoned).
  Written first and confirmed to fail without the guard.
- F2: staleness attaches on query/context/impact object results and on cypher's
  tabular {markdown,row_count}; a raw-array cypher result keeps its shape.
- F3: drift guard — exactly query/cypher/context/impact route through
  stalenessForTool; explain/pdg_query/detect_changes/check do not.
- F4: the per-index cache dedupes within TOOL_STALENESS_TTL_MS and recomputes
  after it expires (driven via a Date.now spy, not fake timers).

Tests (gitnexus/test/unit/analyzer-identity.test.ts):
- F5: the produced identity's build.rootPath and runtime.executablePath are
  normalizer-stable, guarding that both call sites thread through
  normalizeAnalyzerRootPath (trivial on POSIX, a real regression guard on
  Windows CI). Plus a source comment noting the one-time Windows re-analyze on
  first upgrade.

* test(mcp): run #2668 guard on Windows CI, document staleness field, cover staleness edge cases

Addresses the review follow-ups on the staleness work:

- Wire test/unit/analyzer-identity.test.ts into scripts/cross-platform-tests.ts
  (PLATFORM_LOGIC). Its "identity path fields are normalizer-stable" fixpoint is
  the Windows regression guard for the #2668 drive-letter normalization, but
  normalizeAnalyzerRootPath is a POSIX no-op, so the guard was only ever running
  (trivially green) on the Ubuntu full-suite and never on the windows-latest
  matrix where it actually bites. Now it runs where it matters.

- Document the inline `staleness` field on query/context/impact/cypher responses
  in the gitnexus-guide skill (both the .claude source and the shipped
  gitnexus-claude-plugin mirror, kept in sync).

- Add three staleness tests that pin behavior the prior tests only implied:
  * @group-routed calls never get the signal (forwarded before the wrapping
    switch) — locks the intentional skip so it can't silently flip.
  * one in-flight freshness check is shared across truly concurrent calls
    (two dispatched before checkStalenessAsync settles → a single spawn), not
    just sequential reuse of an already-resolved value.
  * a late rejection from a superseded cache entry does not evict the newer
    entry that replaced it after the TTL rolled over (the `=== entry`
    object-identity guard).

The defensive stack in stalenessForTool/withToolStaleness (Promise.resolve
wrap + guarded evict + outer catch) is retained deliberately: the wrap is
load-bearing for the tests (a sibling describe's vi.resetAllMocks() makes the
mock return undefined), and the guarded evict closes the superseded-entry edge
now covered above.

* fix(test): split the #2668 normalization guard into a portable cross-platform file

Registering analyzer-identity.test.ts on the Windows/macOS matrix (previous
commit) surfaced four pre-existing failures in that file on macOS 3/3 and
windows 3/3. They are not new breakage: those fixture tests compare identity
fields against the RAW temp-dir path while the identity resolves through
realpathSync.native, so on macOS `/var/folders/...` is received as
`/private/var/folders/...`. The file was simply never portable — it had only
ever run in the Ubuntu full-suite. Reproduced locally by pointing TMPDIR at a
symlink: the same four tests fail, and pass again without it.

Move only the portable assertions — the pure `normalizeAnalyzerRootPath` cases
(explicit `platform` argument) and the identity fixpoint guard (which compares
each field against ITSELF normalized, never against the fixture path) — into
test/unit/analyzer-identity-path-normalization.test.ts, and register that file
on the matrix instead. The #2668 Windows regression guard still runs where it
actually bites, without dragging four symlink-sensitive tests onto runners they
were never written for.

Verified: the new file passes with TMPDIR behind a symlink (the macOS
condition); the heavy file is back to Ubuntu-only.

* fix(test): keep the cross-platform #2668 file fixture-free so Windows stays green

The split file still carried the fixture-based fixpoint guard, which fails on
windows-latest:

  Invoked analyzer artifact is absent from the validated build:
    D:\a\...\node_modules\vitest\dist\workers\forks.js

Cause is a pre-existing cross-drive defect in this module's `isInside()`, not the
#2668 change. The GH Windows runner keeps the repo on D: and temp fixtures on C:.
`path.win32.relative('C:\\...fixture', 'D:\\...forks.js')` cannot express a
relative path across drives, so it returns the absolute target — which does not
start with '..', so `isInside()` reports true. `resolveInvokedArtifact` therefore
treats the vitest fork worker as the invoked artifact, it is absent from the
fixture's validated build, and identity resolution throws. (Verified directly:
`isInside` returns true cross-drive and false for the same-drive control.)

Keep the cross-platform file strictly pure — only `normalizeAnalyzerRootPath`
assertions with an explicit `platform` argument, no fixture and no filesystem —
so it is green on every runner while still exercising the transform on real
Windows. The fixture-based threading guard moves back to analyzer-identity.test.ts
(Ubuntu-only), where the rest of that file's fixture tests already live, with a
comment recording why it cannot be on the matrix.

The underlying `isInside()` cross-drive bug is left untouched here (out of scope
for this PR) but is worth its own fix: it also guards the trusted cache directory
and the identity-cache path-escape check in validateIdentityCache, where a false
"inside" verdict weakens validation on multi-drive Windows setups.

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-07-25 07:21:44 +01:00

73 lines
2.8 KiB
TypeScript

/**
* #2655: `query`/`context`/`impact`/`cypher` tool responses carry a non-blocking
* `staleness` signal when the index is behind HEAD, mirroring `list_repos`.
*
* These tests cover `attachToolStaleness` — the shape contract that guarantees
* the signal is only ever ADDED to an object result and never mutates an
* existing result's shape (so the CLI's `Array.isArray`-based `--limit` on
* raw-array cypher rows, and any consumer's shape assumptions, keep working).
*/
import { describe, it, expect } from 'vitest';
import type { StalenessInfo } from '../../src/core/git-staleness.js';
import { attachToolStaleness } from '../../src/mcp/local/local-backend.js';
const STALE: StalenessInfo = {
isStale: true,
commitsBehind: 3,
hint: '⚠️ Index is 3 commits behind HEAD. Run analyze tool to update.',
};
const FRESH: StalenessInfo = { isStale: false, commitsBehind: 0 };
describe('attachToolStaleness (#2655)', () => {
it('adds a list_repos-shaped staleness field to an object result when stale', () => {
const out = attachToolStaleness({ processes: [], total: 0 }, STALE);
expect(out).toMatchObject({
processes: [],
total: 0,
staleness: { commitsBehind: 3, hint: STALE.hint },
});
});
it('leaves the result untouched when the index is fresh', () => {
const result = { processes: [], total: 0 };
expect(attachToolStaleness(result, FRESH)).toBe(result);
});
it('never changes the shape of a raw-array result (CLI --limit relies on Array.isArray)', () => {
const rows = [{ a: 1 }, { a: 2 }];
const out = attachToolStaleness(rows, STALE);
expect(Array.isArray(out)).toBe(true);
expect(out).toBe(rows);
});
it('does not annotate an error envelope', () => {
const err = { error: 'LadybugDB not ready. Index may be corrupted.' };
expect(attachToolStaleness(err, STALE)).toBe(err);
});
it('is idempotent — a result that already has staleness is left as-is', () => {
const already = { total: 1, staleness: { commitsBehind: 9, hint: 'x' } };
expect(attachToolStaleness(already, STALE)).toBe(already);
});
it('leaves non-object results (null / primitives) unchanged', () => {
expect(attachToolStaleness(null, STALE)).toBeNull();
expect(attachToolStaleness('markdown text', STALE)).toBe('markdown text');
});
it('is null-safe — a missing staleness info never throws or mutates the result', () => {
const result = { total: 0 };
expect(attachToolStaleness(result, undefined)).toBe(result);
});
it('carries hint through as-is (may be undefined on a stale-without-hint info)', () => {
const out = attachToolStaleness(
{ ok: true },
{
isStale: true,
commitsBehind: 1,
},
) as { staleness: { commitsBehind: number; hint?: string } };
expect(out.staleness).toMatchObject({ commitsBehind: 1 });
});
});