mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* fix(impact): surface scope extraction omissions * fix(impact): preserve complete index fixtures * fix(impact): preserve scope completeness evidence * test(analyze): model successful scope extraction in harnesses --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
276 lines
12 KiB
TypeScript
276 lines
12 KiB
TypeScript
/**
|
|
* B2 — a refresh that reports SUCCESS while leaving the index unusable.
|
|
*
|
|
* The dangerous variant of a broken refresh: metadata IS written, so the index
|
|
* reads as fresh, hooks re-arm, and every tool answers from a graph missing
|
|
* most of its edges — which is indistinguishable from a codebase that
|
|
* genuinely has no such relationships. Observed as edges collapsing
|
|
* 23009 -> 2170, and as a `CodeRelation` table that never materialized (which
|
|
* reads back as a persisted count of zero).
|
|
*
|
|
* `analyze` compares the relationship count the pipeline produced against what
|
|
* the DB hands back after the write and records `graphWriteCollapsed`. This
|
|
* covers the translation of that record into the operator-facing reason, which
|
|
* is what `status` and the MCP resources report.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
detectGraphWriteCollapse,
|
|
getIndexIncompleteReasons,
|
|
GRAPH_WRITE_COLLAPSE_MIN_EDGES,
|
|
GRAPH_WRITE_COLLAPSE_RATIO,
|
|
INDEX_INCOMPLETE_REASONS,
|
|
} from '../../src/core/index-freshness.js';
|
|
|
|
describe('detectGraphWriteCollapse (B2 detection)', () => {
|
|
it('flags the reported field failure (23009 built, 2170 persisted)', () => {
|
|
expect(detectGraphWriteCollapse(23009, 2170)).toEqual({
|
|
verdict: 'collapsed',
|
|
expected: 23009,
|
|
persisted: 2170,
|
|
});
|
|
});
|
|
|
|
it('flags a missing relation table, which reads back as zero persisted', () => {
|
|
expect(detectGraphWriteCollapse(23009, 0)).toEqual({
|
|
verdict: 'collapsed',
|
|
expected: 23009,
|
|
persisted: 0,
|
|
});
|
|
});
|
|
|
|
it('stays silent on a healthy write', () => {
|
|
expect(detectGraphWriteCollapse(23009, 23009)).toEqual({ verdict: 'healthy' });
|
|
});
|
|
|
|
// A surplus is still tolerated — the detector only ever fires on a SHORTFALL,
|
|
// and small overcounts are legitimate (a row written by a path the manifest
|
|
// does not enumerate). What changed is the caller, not this rule.
|
|
it('stays silent when more rows persist than expected', () => {
|
|
expect(detectGraphWriteCollapse(1000, 4000)).toEqual({ verdict: 'healthy' });
|
|
});
|
|
|
|
// THE CASE THIS FILE USED TO PIN THE WRONG WAY, and why the fix is at the
|
|
// CALLER rather than here.
|
|
//
|
|
// The old assertion read `detectGraphWriteCollapse(1000, 4000)` with the
|
|
// comment "PDG layers write into the same table, so persisted > expected is
|
|
// normal". True about the table — and it quietly licensed the masking. The
|
|
// caller passed `stats.edges`, a count of EVERY CodeRelation row, against an
|
|
// expectation covering only the structural halves, so losing all 1,000
|
|
// structural edges while 4,000 PDG rows persisted was indistinguishable from
|
|
// health.
|
|
//
|
|
// Padding `expected` with the PDG rows does NOT fix that, which is worth
|
|
// recording because it is the obvious move: 4,000 persisted against 5,000
|
|
// expected still clears the 0.5 ratio. The ratio would be judging a minority
|
|
// population. `run-analyze.ts` therefore compares STRUCTURAL against
|
|
// STRUCTURAL, using the new `getLbugStats().structuralEdges`.
|
|
//
|
|
// At this level that is simply the ordinary shortfall case: once both sides
|
|
// count structural edges only, a total structural wipeout on a --pdg run is
|
|
// `(1000, 0)` and fires like any other.
|
|
it('fires on a total structural loss even when PDG rows are plentiful', () => {
|
|
expect(detectGraphWriteCollapse(1000, 0)).toEqual({
|
|
verdict: 'collapsed',
|
|
expected: 1000,
|
|
persisted: 0,
|
|
});
|
|
});
|
|
|
|
// REGRESSION. A non-numeric `expected` does not merely skip the guards, it
|
|
// INVERTS them: `undefined < 100` is false so the small-repo exemption never
|
|
// fires, and `0 >= undefined * 0.5` is `0 >= NaN`, also false, so the ratio
|
|
// check "passes" too. Shipped briefly and reported healthy runs as total
|
|
// collapses — the exact false certainty this check exists to prevent.
|
|
it('never fires when the expected count is not a number', () => {
|
|
const unmeasurable = { verdict: 'unmeasurable', reason: 'expected-unavailable' };
|
|
expect(detectGraphWriteCollapse(undefined as unknown as number, 0)).toEqual(unmeasurable);
|
|
expect(detectGraphWriteCollapse(NaN, 0)).toEqual(unmeasurable);
|
|
expect(detectGraphWriteCollapse(Infinity, 0)).toEqual(unmeasurable);
|
|
});
|
|
|
|
it('never fires when the persisted count is not a number', () => {
|
|
// `getLbugStats` returns `{}` under some mocks/degraded paths, so
|
|
// `stats.edges` arrives as undefined rather than a measured zero.
|
|
const unmeasurable = { verdict: 'unmeasurable', reason: 'persisted-unreadable' };
|
|
expect(detectGraphWriteCollapse(23009, undefined)).toEqual(unmeasurable);
|
|
expect(detectGraphWriteCollapse(23009, NaN)).toEqual(unmeasurable);
|
|
});
|
|
|
|
it('is fail-safe when the expected count is unavailable', () => {
|
|
// An implementation that offloads relationships out of memory may report 0;
|
|
// a false "your index is broken" is worse than a missed one.
|
|
//
|
|
// `'unmeasurable'`, deliberately NOT `'healthy'`: a run that compared
|
|
// nothing has repaired nothing, so it must not be allowed to clear a stamp
|
|
// recording an earlier, real collapse.
|
|
const unmeasurable = { verdict: 'unmeasurable', reason: 'expected-unavailable' };
|
|
expect(detectGraphWriteCollapse(0, 0)).toEqual(unmeasurable);
|
|
expect(detectGraphWriteCollapse(0, 5000)).toEqual(unmeasurable);
|
|
});
|
|
|
|
it('exempts small repos where the ratio is meaningless', () => {
|
|
// A PARTIAL shortfall under the threshold — the case the exemption was
|
|
// written for ("a handful of edges lost to legitimate filtering").
|
|
//
|
|
// `'healthy'` rather than `'unmeasurable'`: both counts WERE taken and the
|
|
// comparison did run, so a stamp may be cleared here. Calling the exemption
|
|
// a non-verdict would make the stamp unclearable on any repo that shrank
|
|
// below the threshold — a permanent forced-rebuild wedge.
|
|
const justUnder = GRAPH_WRITE_COLLAPSE_MIN_EDGES - 1;
|
|
expect(detectGraphWriteCollapse(justUnder, justUnder - 1)).toEqual({ verdict: 'healthy' });
|
|
expect(detectGraphWriteCollapse(justUnder, 1)).toEqual({ verdict: 'healthy' });
|
|
});
|
|
|
|
// This assertion previously read `detectGraphWriteCollapse(99, 0) === undefined`,
|
|
// pinning the defect rather than the behaviour: the exemption tested
|
|
// `expected` before looking at `persisted` at all, so a repo that lost EVERY
|
|
// edge was excused for being small, metadata stayed fresh and the CLI
|
|
// reported success. Losing all of a small graph is still losing all of it.
|
|
it('never exempts a TOTAL loss, however small the repo', () => {
|
|
expect(detectGraphWriteCollapse(GRAPH_WRITE_COLLAPSE_MIN_EDGES - 1, 0)).toEqual({
|
|
verdict: 'collapsed',
|
|
expected: GRAPH_WRITE_COLLAPSE_MIN_EDGES - 1,
|
|
persisted: 0,
|
|
});
|
|
expect(detectGraphWriteCollapse(1, 0)).toEqual({
|
|
verdict: 'collapsed',
|
|
expected: 1,
|
|
persisted: 0,
|
|
});
|
|
});
|
|
|
|
// The boundary the total-loss rule must NOT cross: zero expected is the
|
|
// fail-safe "cannot measure" case, not a collapse.
|
|
it('still says nothing when nothing was expected', () => {
|
|
expect(detectGraphWriteCollapse(0, 0)).toEqual({
|
|
verdict: 'unmeasurable',
|
|
reason: 'expected-unavailable',
|
|
});
|
|
});
|
|
|
|
// An unreadable edge count is not a measured zero. `getLbugStats` now returns
|
|
// `undefined` when the query threw, and the total-loss rule must not treat
|
|
// that as a total loss.
|
|
it('does not call an unreadable count a total loss', () => {
|
|
const unmeasurable = { verdict: 'unmeasurable', reason: 'persisted-unreadable' };
|
|
expect(detectGraphWriteCollapse(50, undefined)).toEqual(unmeasurable);
|
|
expect(detectGraphWriteCollapse(5000, undefined)).toEqual(unmeasurable);
|
|
});
|
|
|
|
it('applies exactly at the minimum-edge boundary', () => {
|
|
expect(detectGraphWriteCollapse(GRAPH_WRITE_COLLAPSE_MIN_EDGES, 0)).toEqual({
|
|
verdict: 'collapsed',
|
|
expected: GRAPH_WRITE_COLLAPSE_MIN_EDGES,
|
|
persisted: 0,
|
|
});
|
|
});
|
|
|
|
it('treats the ratio as inclusive — exactly at threshold is not a collapse', () => {
|
|
const expected = 1000;
|
|
const atThreshold = expected * GRAPH_WRITE_COLLAPSE_RATIO;
|
|
expect(detectGraphWriteCollapse(expected, atThreshold)).toEqual({ verdict: 'healthy' });
|
|
expect(detectGraphWriteCollapse(expected, atThreshold - 1)).toEqual({
|
|
verdict: 'collapsed',
|
|
expected,
|
|
persisted: atThreshold - 1,
|
|
});
|
|
});
|
|
|
|
// Every verdict must be one of the three tags — an outcome that is neither a
|
|
// measured collapse, a measured all-clear, nor an explicit non-verdict is how
|
|
// "could not measure" got to look like "measured fine" in the first place.
|
|
it('never returns an untagged or absent verdict', () => {
|
|
const inputs: [number, number | undefined][] = [
|
|
[23009, 2170],
|
|
[23009, 23009],
|
|
[1000, 4000],
|
|
[0, 0],
|
|
[0, 5000],
|
|
[99, 0],
|
|
[99, 98],
|
|
[100, 0],
|
|
[10000, undefined],
|
|
[NaN, 0],
|
|
];
|
|
for (const [expected, persisted] of inputs) {
|
|
const verdict = detectGraphWriteCollapse(expected, persisted);
|
|
expect(['collapsed', 'healthy', 'unmeasurable']).toContain(verdict.verdict);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('graph-write-collapsed incomplete reason (B2)', () => {
|
|
it('is part of the stable reason vocabulary', () => {
|
|
expect(INDEX_INCOMPLETE_REASONS).toContain('graph-write-collapsed');
|
|
});
|
|
|
|
it('reports a collapsed write as incomplete rather than fresh', () => {
|
|
expect(
|
|
getIndexIncompleteReasons({
|
|
graphWriteCollapsed: { expected: 23009, persisted: 2170 },
|
|
scopeExtractionReceipt: 1,
|
|
}),
|
|
).toEqual(['graph-write-collapsed']);
|
|
});
|
|
|
|
it('treats a missing relation table (zero persisted) the same way', () => {
|
|
expect(
|
|
getIndexIncompleteReasons({
|
|
graphWriteCollapsed: { expected: 23009, persisted: 0 },
|
|
scopeExtractionReceipt: 1,
|
|
}),
|
|
).toEqual(['graph-write-collapsed']);
|
|
});
|
|
|
|
it('says nothing on a healthy run', () => {
|
|
expect(getIndexIncompleteReasons({ scopeExtractionReceipt: 1 })).toEqual([]);
|
|
});
|
|
|
|
it('marks missing metadata or receipt as scope-extraction-unverified', () => {
|
|
expect(INDEX_INCOMPLETE_REASONS).toContain('scope-extraction-unverified');
|
|
expect(getIndexIncompleteReasons({})).toEqual(['scope-extraction-unverified']);
|
|
expect(getIndexIncompleteReasons(null)).toEqual(['scope-extraction-unverified']);
|
|
});
|
|
|
|
it('reports alongside other reasons rather than masking them', () => {
|
|
const reasons = getIndexIncompleteReasons({
|
|
incrementalInProgress: { startedAt: 1, toWriteCount: 0 },
|
|
graphWriteCollapsed: { expected: 500, persisted: 10 },
|
|
scopeExtractionReceipt: 1,
|
|
});
|
|
expect(reasons).toContain('incremental-in-progress');
|
|
expect(reasons).toContain('graph-write-collapsed');
|
|
});
|
|
});
|
|
|
|
describe('scope-extraction-failed incomplete reason (#3015)', () => {
|
|
it('is stable and reports a partial scope index as incomplete', () => {
|
|
expect(INDEX_INCOMPLETE_REASONS).toContain('scope-extraction-failed');
|
|
expect(
|
|
getIndexIncompleteReasons({
|
|
scopeExtractionReceipt: 1,
|
|
scopeExtractionFailures: { total: 2, paths: ['src/a.ts', 'src/b.ts'] },
|
|
}),
|
|
).toContain('scope-extraction-failed');
|
|
});
|
|
|
|
it('does not report a malformed zero-count record as incomplete', () => {
|
|
expect(
|
|
getIndexIncompleteReasons({
|
|
scopeExtractionReceipt: 1,
|
|
scopeExtractionFailures: { total: 0, paths: [] },
|
|
}),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('marks malformed summaries as unverified even when the receipt is present', () => {
|
|
expect(
|
|
getIndexIncompleteReasons({
|
|
scopeExtractionReceipt: 1,
|
|
scopeExtractionFailures: { total: Number.NaN, paths: [] },
|
|
}),
|
|
).toEqual(['scope-extraction-unverified']);
|
|
});
|
|
});
|