GitNexus/gitnexus/test/unit/rebuild-reasons.test.ts
Gergő Magyar 0261982d9a
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(analyze): make --memory-budget set the real heap and report rebuild reasons once (#3386)
* feat(analyze): --memory-budget flag with heap-limit override and worker-pool degradation (#3137)

Adds an explicit `--memory-budget <mb>` CLI flag that overrides the
RAM/cgroup auto-sized main-thread heap ceiling for the parse phase:

- CLI validation (integer >= 200 MB) before bar.start(), matching the
  --workers pattern
- Threaded CLI → runFullAnalysis → PipelineOptions → parse-impl as
  memoryBudgetBytes
- parse-impl resolves the heap limit as budget ?? v8.heap_size_limit, so
  both the preflight projection warning and the #2649 mid-loop abort
  probe honor the budget
- Graceful degradation: when the projected heap need exceeds the budget
  at the computed pool size, the pool shrinks (never below 1, never
  above the operator's --workers) before sub-batch math and pool
  construction, so all downstream consumers see the degraded size

Omitting the flag keeps the auto-sizer path byte-identical.

Refs #3137

* feat(analyze): collapse rebuild-gate log into one summary + persist needsFullRebuild verdict (#3137)

The nine meta-mismatch rebuild gates (pdg mode, content retention,
schema fingerprint, graph-write collapse, analysis features, Spring
vendor prefixes, runner identity, FTS CJK mode, embedding dims) each
logged individually and set force:true independently. An upgrade that
trips several at once printed a scattered wall of near-identical
warnings.

- Gates now collect into rebuildReasons[]; a single summary block
  prints them (inline for one, numbered for many) and sets force once.
  Per-gate Tip text is preserved verbatim inside the entries.
- The verdict persists to meta (needsFullRebuild: {reasons, recordedAt})
  BEFORE the rebuild starts. If the rebuild is interrupted, the next
  run announces the recorded reasons up front instead of quietly
  attempting an incremental write on a half-rebuilt index — the gates
  may not all re-fire against a wiped DB.
- The verdict is cleared on the next successful completion (the final
  meta does not carry the field forward).

Semantics unchanged: every gate was already evaluated (none
early-returns), force is idempotent, and a rebuild happens iff at
least one reason fired.

Refs #3137

* refactor(cli): share one integer flag parser across analyze, watch, and wiki

Replace the duplicated Number.isInteger checks for --workers, --embeddings,
the positive env-backed analyze flags, the watch interval flags, and wiki's
--timeout/--retries with parseIntegerOption (per-flag minimum, optional
scale for the safe-integer bound). User-facing messages are unchanged.

* fix(analyze): make --memory-budget set the real V8 heap through the respawn

The budget now drives ensureHeap's existing respawn instead of a parse-phase
override, so the #2649 preflight, mid-loop abort, remedy text, and GC pacing
all see one heap limit. The respawn sizes old space plus three semi-spaces to
equal the budget, the child resolves as already at the budget (no second
respawn), and GITNEXUS_HEAP_LIMIT_SOURCE drives budget-aware OOM advice.
Budget validation moves to the preAction hook so analyze and watch reject a
bad value before any respawn. Removes the pool-shrink block and the
memoryBudgetBytes plumbing through PipelineOptions and run-analyze.

* docs(analyze): describe --memory-budget accurately and translate its help

The help text claimed graceful worker-pool degradation, which no longer
exists; it now says the flag sets the main-thread V8 heap and that parse
workers keep their own caps. Wires the option through the help i18n map
with en and zh-CN strings, and documents it in both READMEs and the
out-of-memory troubleshooting section.

* feat(analyze): add a pure rebuild-reason collector

One collector per run holds keyed rebuild reasons, merges by key, flattens
reasons stored by an interrupted rebuild into one recovery entry, validates
stored reasons on read, and formats the single up-front summary plus one
follow-up line for reasons added after the pipeline.

* fix(analyze): route every forced rebuild through one reason collector

Every path that forces a full rebuild (the nine meta gates, --force,
--skills, --no-parse-cache, --drop-embeddings, --repair-fts retention,
Spring Actuator, AsyncAPI, shared-store graph gaps, dirty-flag recovery,
the post-pipeline capability gate, and the #2409 escalation) now adds a
keyed reason to one collector. The rebuild decision is applied from the
collector at fixed checkpoints, one summary prints right before the
pipeline, and late reasons print one follow-up line. The escalation stays
non-forcing. runFullAnalysis returns the collected keys, which replaces the
runner-identity source-regex test with a behavior test. Removes the separate
needsFullRebuild field and its announcement, and stops folding --skills and
--no-parse-cache into --force.

* fix(analyze): persist rebuild reasons on the existing crash marker

Every incrementalInProgress writer (the full-rebuild stamp before the wipe,
the incremental pre-write, saveIncrementalDirtyState including the #2409
escalation, and buildFtsDirtyStamp) now carries the collected reasons into
the active slot's metaDir, so an interrupted rebuild explains itself on the
next run through one merged recovery entry. A successful run still clears
the marker and its reasons; the FTS-park recovery clears them without
forcing.

* test(analyze): cover every rebuild-reason key through runFullAnalysis

Add a coverage table that the typechecker keeps complete: every
RebuildReasonKey maps to a test file that drives it through
runFullAnalysis and asserts the returned key. Adds the missing
graph-write-collapse and drop-embeddings drivers, asserts the key in the
existing pdg-mode, spring-vendor-prefixes, cjk-segmentation, and
embedding-dims tests, and removes plan-local IDs from test names and
comments.

* fix(review): apply review findings

- A --max-old-space-size pin equal to --memory-budget no longer counts as
  the exact budget heap (V8 adds the young generation on top); only the
  budget-respawned child skips the respawn, so the limit really equals the
  budget.
- Snapshot the analyze env before ensureHeap and restore
  GITNEXUS_HEAP_LIMIT_SOURCE, so a kept process does not leak its heap
  source into a later programmatic analyzeCommand call.
- --skills and --no-parse-cache keep the forced storage requirements they
  had before force stopped being folded from them.
- Merge the duplicated follow-up announcement into one helper and fix a
  stale --drop-embeddings comment.
- The rebuild-reason coverage table no longer greps driver files for the
  key string; add tests for a programmatic invalid budget and the
  multi-cause interrupted-rebuild text.

* fix(review): don't announce the escalated write as a full rebuild

The #2409 escalation is a non-forcing reason, but its follow-up line used
the 'Full rebuild also required' lead. A follow-up that carries only
non-forcing reasons now leads with 'Write plan changed'.

* docs(analyze): document GITNEXUS_HEAP_LIMIT_SOURCE in the env table

CONTRIBUTING requires every new GITNEXUS_* variable to have a row; this one
is internal (set by analyze itself) and exists so OOM advice points at
--memory-budget.

* fix(review): address GitNexus review threads on #3386

- heapPressureRemedy measures pressure against the real auto-sized cap
  (heapCapMbFor) instead of a flat 0.75 x RAM, and no longer tells a
  GITNEXUS_MEMORY=off run with no pin to drop a pin that does not exist.
- toStored() persists the interrupted rebuild's reasons first, as documented.
- ensureHeap's doc names which paths leave GITNEXUS_HEAP_LIMIT_SOURCE unset.
- The heap-respawn suite restores the caller's GITNEXUS_MEMORY.
- The non-forcing follow-up test rejects any 'full rebuild' wording.

* fix(review): require both budget flags and check key coverage at runtime

- A budget-respawned child is recognized only when the inherited heap-source
  marker comes with both the budget's old-space and semi-space flags; the
  marker alone is an inherited env var, not proof. The old-space parser is
  generalized to any V8 size flag instead of copying its regex.
- REBUILD_REASON_KEYS is exported and RebuildReasonKey derives from it, so the
  coverage table is checked at runtime (CI does not type-check test files).

* fix(review): don't claim a full rebuild in a non-forcing summary

formatSummary and formatFollowUp now share one leadFor helper, so a block
of only non-forcing reasons reads 'Write plan changed' in both.

---------

Co-authored-by: ChunxueLi <mecoloud@users.noreply.gitee.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-09-26 18:14:36 +01:00

327 lines
14 KiB
TypeScript

import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
REBUILD_REASON_KEYS,
RebuildReasonCollector,
readStoredRebuildReasons,
type RebuildReason,
type RebuildReasonKey,
} from '../../src/core/rebuild-reasons.js';
const schema: RebuildReason = { key: 'schema-fingerprint', text: 'index schema changed (A → B)' };
const runner: RebuildReason = { key: 'runner-identity', text: 'analyzer runner identity changed' };
const actuator: RebuildReason = {
key: 'spring-actuator',
text: 'Spring Actuator runtime enrichment requested',
};
const escalation: RebuildReason = {
key: 'escalated-full-write',
text: 'incremental write escalated to a full write; re-run with --force if it fails',
forcing: false,
};
describe('RebuildReasonCollector summary', () => {
it('formats a single reason inline', () => {
const collector = new RebuildReasonCollector();
collector.add(schema);
expect(collector.formatSummary()).toBe(`Full rebuild required: ${schema.text}`);
});
it('formats three reasons as one numbered block', () => {
const collector = new RebuildReasonCollector();
collector.add(schema);
collector.add(runner);
collector.add(actuator);
expect(collector.formatSummary()).toBe(
'Full rebuild required (3 reasons):\n' +
` 1. ${schema.text}\n` +
` 2. ${runner.text}\n` +
` 3. ${actuator.text}`,
);
expect(collector.keys()).toEqual(['schema-fingerprint', 'runner-identity', 'spring-actuator']);
});
it('reports not forced and formats nothing when empty', () => {
const collector = new RebuildReasonCollector();
expect(collector.forced).toBe(false);
expect(collector.keys()).toEqual([]);
expect(collector.formatSummary()).toBeUndefined();
expect(collector.formatFollowUp()).toBeUndefined();
});
it('merges the same key into one entry with the newest text, keeping its position', () => {
const collector = new RebuildReasonCollector();
collector.add({ key: 'content-retention', text: 'repair-fts retention force' });
collector.add(schema);
collector.add({ key: 'content-retention', text: 'content retention changed' });
expect(collector.reasons()).toEqual([
{ key: 'content-retention', text: 'content retention changed' },
schema,
]);
});
it('reports forced once any forcing reason is present', () => {
const collector = new RebuildReasonCollector();
collector.add(runner);
expect(collector.forced).toBe(true);
});
it('does not claim a full rebuild in a follow-up that carries only non-forcing reasons', () => {
const collector = new RebuildReasonCollector();
collector.formatSummary();
collector.add(escalation);
const followUp = collector.formatFollowUp() ?? '';
expect(followUp).toContain(escalation.text);
expect(followUp).not.toMatch(/full rebuild/i);
});
it('lists a non-forcing reason without reporting forced', () => {
const collector = new RebuildReasonCollector();
collector.add(escalation);
expect(collector.forced).toBe(false);
expect(collector.keys()).toEqual(['escalated-full-write']);
const summary = collector.formatSummary() ?? '';
expect(summary).toContain(escalation.text);
expect(summary).not.toMatch(/full rebuild/i);
});
});
describe('RebuildReasonCollector follow-up', () => {
it('formats two late reasons as one line covering only the unannounced ones', () => {
const collector = new RebuildReasonCollector();
collector.add(schema);
collector.formatSummary();
collector.add({ key: 'analysis-features', text: 'analysis capabilities changed' });
collector.add(escalation);
const followUp = collector.formatFollowUp();
expect(followUp?.split('\n')).toHaveLength(1);
expect(followUp).toContain('analysis capabilities changed');
expect(followUp).toContain(escalation.text);
expect(followUp).not.toContain(schema.text);
});
it('keeps a follow-up on one line when a late reason text spans lines', () => {
const collector = new RebuildReasonCollector();
collector.formatSummary();
collector.add({ key: 'analysis-features', text: 'first line\nsecond line' });
expect(collector.formatFollowUp()?.split('\n')).toHaveLength(1);
});
it('formats nothing when no reason arrived after the summary', () => {
const collector = new RebuildReasonCollector();
collector.add(schema);
collector.formatSummary();
expect(collector.formatFollowUp()).toBeUndefined();
});
it('does not repeat a follow-up once it was announced', () => {
const collector = new RebuildReasonCollector();
collector.formatSummary();
collector.add(escalation);
collector.formatFollowUp();
expect(collector.formatFollowUp()).toBeUndefined();
});
it('treats a late re-add of an announced key as already announced', () => {
const collector = new RebuildReasonCollector();
collector.add(schema);
collector.formatSummary();
collector.add({ key: 'schema-fingerprint', text: 'index schema changed again' });
expect(collector.formatFollowUp()).toBeUndefined();
});
});
describe('RebuildReasonCollector interrupted-rebuild recovery', () => {
it('names the interrupted rebuild and its stored reason in one forcing entry', () => {
const collector = new RebuildReasonCollector();
collector.recordInterruptedRebuild([schema], 'phase=load-graph');
expect(collector.keys()).toEqual(['interrupted-rebuild']);
expect(collector.forced).toBe(true);
const text = collector.reasons()[0].text;
expect(text).toContain('did not complete cleanly');
expect(text).toContain('phase=load-graph');
expect(text).toContain(schema.text);
});
it('merges a re-detected reason with a matching key into the entry', () => {
const collector = new RebuildReasonCollector();
collector.recordInterruptedRebuild([schema]);
collector.add({ key: 'schema-fingerprint', text: 'index schema changed (B → C)' });
collector.add(runner);
const summary = collector.formatSummary() ?? '';
expect(collector.keys()).toEqual(['interrupted-rebuild', 'runner-identity']);
expect(summary.split('index schema changed')).toHaveLength(2);
expect(summary).toContain('index schema changed (B → C)');
expect(summary).not.toContain(schema.text);
});
it('does not re-announce the interrupted-rebuild entry when a matching reason merges after the summary', () => {
const collector = new RebuildReasonCollector();
collector.recordInterruptedRebuild([schema]);
collector.formatSummary();
collector.add({ key: 'schema-fingerprint', text: 'index schema changed (B → C)' });
expect(collector.formatFollowUp()).toBeUndefined();
expect(collector.toStored()).toEqual([
{ key: 'schema-fingerprint', text: 'index schema changed (B → C)' },
]);
});
it('absorbs a matching reason that was added before the recovery entry', () => {
const collector = new RebuildReasonCollector();
collector.add({ key: 'schema-fingerprint', text: 'index schema changed (B → C)' });
collector.recordInterruptedRebuild([schema]);
expect(collector.keys()).toEqual(['interrupted-rebuild']);
expect(collector.reasons()[0].text).toContain('index schema changed (B → C)');
});
it('flattens stored reasons that already contain an interrupted-rebuild entry', () => {
const collector = new RebuildReasonCollector();
collector.recordInterruptedRebuild([
{ key: 'interrupted-rebuild', text: 'Previous analyze run did not complete cleanly' },
schema,
]);
const text = collector.reasons()[0].text;
expect(text.split('did not complete cleanly')).toHaveLength(2);
expect(collector.toStored()).toEqual([schema]);
});
it('describes a marker with no stored reasons as having no reasons recorded', () => {
const collector = new RebuildReasonCollector();
collector.recordInterruptedRebuild([]);
expect(collector.reasons()[0].text).toContain('no reasons recorded');
expect(collector.forced).toBe(true);
});
it('numbers each stored cause inside a multi-cause interrupted-rebuild entry', () => {
const collector = new RebuildReasonCollector();
collector.recordInterruptedRebuild([schema, { key: 'future-key', text: 'from a newer build' }]);
expect(collector.reasons()[0].text).toContain(`(1) ${schema.text}; (2) from a newer build`);
});
it('persists the interrupted-rebuild reasons before reasons collected earlier in this run', () => {
const collector = new RebuildReasonCollector();
collector.add({ key: 'user-force', text: 'forced' });
collector.recordInterruptedRebuild([schema]);
expect(collector.toStored().map((r) => r.key)).toEqual(['schema-fingerprint', 'user-force']);
});
it('persists the flattened, merged reasons with no interrupted-rebuild entry', () => {
const collector = new RebuildReasonCollector();
collector.recordInterruptedRebuild([schema, { key: 'future-key', text: 'from a newer build' }]);
collector.add({ key: 'schema-fingerprint', text: 'index schema changed (B → C)' });
collector.add(escalation);
expect(collector.toStored()).toEqual([
{ key: 'schema-fingerprint', text: 'index schema changed (B → C)' },
{ key: 'future-key', text: 'from a newer build' },
{ key: escalation.key, text: escalation.text },
]);
});
it('keeps flattening across two consecutive crashes', () => {
const first = new RebuildReasonCollector();
first.recordInterruptedRebuild([schema]);
first.add(runner);
const second = new RebuildReasonCollector();
second.recordInterruptedRebuild(readStoredRebuildReasons(first.toStored()));
expect(second.toStored()).toEqual([schema, runner]);
expect(second.reasons()[0].text.split('did not complete cleanly')).toHaveLength(2);
});
});
describe('readStoredRebuildReasons', () => {
it('keeps entries with string key and text, including unknown keys', () => {
expect(
readStoredRebuildReasons([
schema,
{ key: 'future-key', text: 'from a newer build' },
{ key: 7, text: 'bad key' },
{ key: 'runner-identity' },
null,
'loose string',
]),
).toEqual([
{ key: 'schema-fingerprint', text: schema.text },
{ key: 'future-key', text: 'from a newer build' },
]);
});
it('drops extra fields so the result is the serializable shape', () => {
expect(readStoredRebuildReasons([{ ...escalation, extra: 1 }])).toEqual([
{ key: escalation.key, text: escalation.text },
]);
});
it.each([
['undefined', undefined],
['a boolean legacy marker', true],
['an object', { key: 'schema-fingerprint', text: 'x' }],
['a string', 'schema-fingerprint'],
])('reads %s as no reasons recorded', (_label, value) => {
const stored = readStoredRebuildReasons(value);
expect(stored).toEqual([]);
const collector = new RebuildReasonCollector();
collector.recordInterruptedRebuild(stored);
expect(collector.reasons()[0].text).toContain('no reasons recorded');
});
it('keeps an unknown stored key text inside the recovery entry', () => {
const collector = new RebuildReasonCollector();
collector.recordInterruptedRebuild(
readStoredRebuildReasons([{ key: 'future-key', text: 'from a newer build' }]),
);
expect(collector.reasons()[0].text).toContain('from a newer build');
});
});
/**
* R15 coverage table: every `RebuildReasonKey` paired with the test file that
* drives it through `runFullAnalysis` and asserts the key. The exhaustiveness
* check below fails `tsc` when a key is added to the union without a row here.
*/
const REBUILD_REASON_COVERAGE = [
{ key: 'user-force', file: 'test/unit/incremental-orchestration.test.ts' },
{ key: 'skills', file: 'test/unit/stream-graph-emit-force-ordering.test.ts' },
{ key: 'parse-cache-bypass', file: 'test/unit/incremental-orchestration.test.ts' },
{ key: 'drop-embeddings', file: 'test/unit/stream-graph-emit-force-ordering.test.ts' },
{ key: 'interrupted-rebuild', file: 'test/unit/incremental-orchestration.test.ts' },
{ key: 'private-graph-unavailable', file: 'test/integration/shared-store-analyze.test.ts' },
{ key: 'shared-store-missing-graph', file: 'test/integration/shared-store-analyze.test.ts' },
{
key: 'content-retention',
file: 'test/integration/external-storage-content-retention.test.ts',
},
{ key: 'pdg-mode', file: 'test/unit/pdg-mode-flip.test.ts' },
{ key: 'schema-fingerprint', file: 'test/unit/incremental-orchestration.test.ts' },
{ key: 'graph-write-collapse', file: 'test/unit/incremental-orchestration.test.ts' },
{ key: 'analysis-features', file: 'test/unit/incremental-orchestration.test.ts' },
{ key: 'spring-vendor-prefixes', file: 'test/unit/incremental-orchestration.test.ts' },
{ key: 'runner-identity', file: 'test/unit/incremental-orchestration.test.ts' },
{ key: 'cjk-segmentation', file: 'test/unit/incremental-orchestration.test.ts' },
{ key: 'embedding-dims', file: 'test/unit/embedding-dims-guard.test.ts' },
{ key: 'spring-actuator', file: 'test/unit/incremental-orchestration.test.ts' },
{ key: 'asyncapi', file: 'test/unit/incremental-orchestration.test.ts' },
{ key: 'escalated-full-write', file: 'test/unit/incremental-orchestration.test.ts' },
] as const satisfies readonly { readonly key: RebuildReasonKey; readonly file: string }[];
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
describe('rebuild reason coverage table (R15)', () => {
it('lists every key exactly once', () => {
const keys: readonly RebuildReasonKey[] = REBUILD_REASON_COVERAGE.map(({ key }) => key);
// Runtime check against the exported key list: CI does not type-check
// test files, so a compile-time-only gate would never fire.
expect([...keys].sort()).toEqual([...REBUILD_REASON_KEYS].sort());
expect(new Set(keys).size).toBe(keys.length);
});
// The runtime check only proves each named driver file exists; the
// behavior proof is the driver test itself, which runs runFullAnalysis and
// asserts the returned key. Grepping the file for the key would pass on a
// stale string, so it is deliberately not asserted here.
it.each(REBUILD_REASON_COVERAGE)('$key is driven by $file', ({ file }) => {
const absolute = path.join(packageRoot, file);
expect(existsSync(absolute)).toBe(true);
});
});