GitNexus/gitnexus/test/unit/tool-staleness.test.ts

218 lines
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 { stalenessPayload, type IndexedRef } from '../../src/core/staleness-status.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 });
});
});
describe('attachToolStaleness — status (#3256)', () => {
it('labels a counted gap as behind', () => {
const out = attachToolStaleness({ ok: true }, STALE) as { staleness: unknown };
expect(out.staleness).toEqual({ status: 'behind', commitsBehind: 3, hint: STALE.hint });
});
it('attaches diverged with its hint and no invented count', () => {
const out = attachToolStaleness(
{ ok: true },
{ isStale: false, commitsBehind: 0, status: 'diverged', hint: 'HEAD moved on' },
) as { staleness: Record<string, unknown> };
expect(out.staleness).toEqual({ status: 'diverged', hint: 'HEAD moved on' });
expect('commitsBehind' in out.staleness).toBe(false);
});
it('does not attach unknown to a hot read tool result', () => {
// A `--skip-git` folder has no history to measure; repeating that on every
// read tool response is noise, so `unknown` stays off this path.
const result = { ok: true };
expect(
attachToolStaleness(result, { isStale: false, commitsBehind: 0, status: 'unknown' }),
).toBe(result);
});
it('still leaves a current index untouched', () => {
const result = { ok: true };
expect(
attachToolStaleness(result, { isStale: false, commitsBehind: 0, status: 'current' }),
).toBe(result);
});
});
// ── #3291: the ref-carrying form ─────────────────────────────────────────────
//
// `stalenessPayload` is the single builder behind three surfaces, so the fix has
// to add a shape without disturbing the one already in use. These pin both
// halves: WITHOUT a ref the output is bit-identical to the pre-#3291 shape for
// every status — which is what keeps `list_repos` and the `serve` repo routes
// byte-stable and their exact-match tests passing unmodified — and WITH one it
// names the index it describes, including when that index is `current`.
const REF: IndexedRef = {
branch: 'feature/x',
lastCommit: 'f'.repeat(40),
indexedAt: '2026-09-15T00:00:00Z',
};
const CURRENT: StalenessInfo = { isStale: false, commitsBehind: 0, status: 'current' };
const UNKNOWN: StalenessInfo = { isStale: false, commitsBehind: 0, status: 'unknown' };
const DIVERGED: StalenessInfo = {
isStale: false,
commitsBehind: 0,
hint: 'moved on',
status: 'diverged',
};
const BEHIND: StalenessInfo = {
isStale: true,
commitsBehind: 3,
hint: '3 behind',
status: 'behind',
};
describe('stalenessPayload without a ref — unchanged by #3291', () => {
it('omits the payload entirely for a current index', () => {
expect(stalenessPayload(CURRENT)).toBeUndefined();
});
it('omits unknown unless the caller asks, and emits it bare when it does', () => {
expect(stalenessPayload(UNKNOWN)).toBeUndefined();
expect(stalenessPayload(UNKNOWN, { includeUnknown: true })).toEqual({ status: 'unknown' });
});
it('reports diverged with its hint and no invented count', () => {
expect(stalenessPayload(DIVERGED)).toEqual({ status: 'diverged', hint: 'moved on' });
});
it('reports behind with the counted gap', () => {
expect(stalenessPayload(BEHIND)).toEqual({
status: 'behind',
commitsBehind: 3,
hint: '3 behind',
});
});
});
describe('stalenessPayload with a ref (#3291)', () => {
it('emits a current index instead of suppressing it, naming the ref', () => {
// The regression #3291 reported: `current` alone cannot distinguish an index
// of the default branch from one of a feature branch.
expect(stalenessPayload(CURRENT, { ref: REF })).toEqual({
status: 'current',
branch: 'feature/x',
lastCommit: REF.lastCommit,
indexedAt: REF.indexedAt,
measuredAgainst: 'HEAD',
});
});
it('names the ref on unknown too — which index answered is knowable when its freshness is not', () => {
expect(stalenessPayload(UNKNOWN, { ref: REF })).toEqual({
status: 'unknown',
branch: 'feature/x',
lastCommit: REF.lastCommit,
indexedAt: REF.indexedAt,
measuredAgainst: 'HEAD',
});
});
it('keeps diverged free of an invented count while carrying the ref', () => {
const out = stalenessPayload(DIVERGED, { ref: REF });
expect(out).toEqual({
status: 'diverged',
branch: 'feature/x',
lastCommit: REF.lastCommit,
indexedAt: REF.indexedAt,
measuredAgainst: 'HEAD',
hint: 'moved on',
});
expect('commitsBehind' in (out ?? {})).toBe(false);
});
it('carries the counted gap alongside the ref', () => {
expect(stalenessPayload(BEHIND, { ref: REF })).toEqual({
status: 'behind',
branch: 'feature/x',
lastCommit: REF.lastCommit,
indexedAt: REF.indexedAt,
measuredAgainst: 'HEAD',
commitsBehind: 3,
hint: '3 behind',
});
});
it('omits branch for a detached HEAD or legacy index, keeping lastCommit as the identifier', () => {
const { branch: _unlabelled, ...noBranch } = REF;
expect(stalenessPayload(CURRENT, { ref: noBranch })).toEqual({
status: 'current',
lastCommit: REF.lastCommit,
indexedAt: REF.indexedAt,
measuredAgainst: 'HEAD',
});
});
});