GitNexus/gitnexus/test/unit/process-detection-budget.test.ts
Gergő Magyar ba39d5c009
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-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
feat(analyze): expose process-detection budget overrides (#3324)
* feat(analyze): expose process-detection budget overrides (#3313)

Operators can raise or lower process count, branching, trace depth, and the entry-point candidate pool via CLI, .gitnexusrc, or GITNEXUS_* without changing shipped defaults. A budget-only change re-detects flows on the next analyze without --force.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(review): say invalid budget flags still honor env

A rejected --max-processes value was described as falling back to the built-in default even when GITNEXUS_MAX_* still won the next precedence tier.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(analyze): share process-detection defaults and skip unused walks

Keep DEFAULT_CONFIG aligned with the budget resolver and count symbols only when maxProcesses is still dynamic.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(analyze): wrap process-detection budget files for prettier

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(analyze): name the real process-detection default formula

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(analyze): stop calling maxProcesses*2 a hard trace quota

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(analyze): say invalid env budget tokens fall back to defaults

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(analyze): recertify process-detection after in-place FTS abort (#3324)

Persist processDetection.uncertified on the in-place FTS dirty stamp when
the budget mismatched so a flagless retry cannot keep rewritten flows.
Qualify .gitnexusrc fail-fast copy and tighten related tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(analyze): skip live dirty stamp on atomic incremental (#3324)

POSIX atomic incremental mutates a staging copy, so stamping live incrementalInProgress before swap made a crash force-rebuild a healthy index. Align analyze --help with CLI > .gitnexusrc > env > default.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(changelog): drop the atomic-incremental dirty-stamp note

The code fix stays; Unreleased no longer lists that recovery change.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(cli): survive FTS SIGSEGV in --limit e2e

CREATE_FTS_INDEX can kill the setup analyze on some WSL hosts
(status null). Rebuild with --skip-fts and skip BM25-only
query --limit cases unless GITNEXUS_REQUIRE_FTS=1.

Refs #3324

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(cli): mark update-check child at import

Writing refresh-started from fetch() raced a 30s poll against
cold tsx boot on a loaded default-project worker.

Refs #3324

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3324)

Isolate default-budget FTS crash-marker tests from GITNEXUS_MAX_* env, assert uncertify-before-FTS order and deferred flow detection on park recovery, drop the dangling "then" from entry-point help, and correct stale streamGraphEmit docs without skipping the process-detection stamp.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(changelog): drop Unreleased process-detection notes

Keep the #3313 / #3322 code; Unreleased changelog matches main until release.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 13:37:15 +01:00

235 lines
8.6 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import {
buildProcessDetectionPhaseConfig,
formatInvalidProcessDetectionOverride,
formatProcessDetectionBudgetBanner,
formatWholeFlowsMissingRemedies,
parsePositiveIntegerOverride,
parseProcessDetectionBudgetStrings,
processDetectionBudgetMismatch,
processDetectionEffectiveLimits,
resolveProcessDetectionBudget,
toProcessDetectionStamp,
uncertifyProcessDetectionStamp,
} from '../../src/core/ingestion/process-detection-budget.js';
describe('parsePositiveIntegerOverride', () => {
it('accepts positive integers and rejects 0 / non-integers', () => {
const invalid: string[] = [];
expect(parsePositiveIntegerOverride('25')).toBe(25);
expect(parsePositiveIntegerOverride(40)).toBe(40);
expect(parsePositiveIntegerOverride('0', (raw) => invalid.push(raw))).toBeUndefined();
expect(parsePositiveIntegerOverride('abc', (raw) => invalid.push(raw))).toBeUndefined();
expect(parsePositiveIntegerOverride('-3', (raw) => invalid.push(raw))).toBeUndefined();
expect(parsePositiveIntegerOverride('', (raw) => invalid.push(raw))).toBeUndefined();
expect(invalid).toEqual(['0', 'abc', '-3', '']);
});
});
describe('resolveProcessDetectionBudget (#3313)', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('keeps shipped defaults when nothing is set', () => {
const resolved = resolveProcessDetectionBudget({}, {});
expect(resolved.maxProcesses).toBeUndefined();
expect(resolved.maxProcessBranching).toBe(4);
expect(resolved.maxProcessTraceDepth).toBe(10);
expect(resolved.maxEntryPointCandidates).toBe(200);
expect(resolved.overridden).toEqual({
maxProcesses: false,
maxProcessBranching: false,
maxProcessTraceDepth: false,
maxEntryPointCandidates: false,
});
});
it('lets an invalid option fall through to env instead of claiming a hard default', () => {
const invalid: Array<[string, string]> = [];
const resolved = resolveProcessDetectionBudget(
{ maxProcesses: 0 },
{ GITNEXUS_MAX_PROCESSES: '80' },
(knob, raw) => invalid.push([knob, raw]),
);
expect(resolved.maxProcesses).toBe(80);
expect(invalid).toEqual([['--max-processes', '0']]);
expect(formatInvalidProcessDetectionOverride('--max-processes', '0')).toContain(
'next source (env, then the built-in default)',
);
expect(formatInvalidProcessDetectionOverride('GITNEXUS_MAX_PROCESSES', '0')).toContain(
'the built-in default',
);
expect(formatInvalidProcessDetectionOverride('GITNEXUS_MAX_PROCESSES', '0')).not.toContain(
'next source (env, then the built-in default)',
);
});
it('lets explicit options beat env (AE3 remainder)', () => {
const resolved = resolveProcessDetectionBudget(
{ maxProcesses: 25 },
{ GITNEXUS_MAX_PROCESSES: '80' },
);
expect(resolved.maxProcesses).toBe(25);
expect(resolved.overridden.maxProcesses).toBe(true);
});
it('reads env when option fields are unset', () => {
const resolved = resolveProcessDetectionBudget(
{},
{
GITNEXUS_MAX_PROCESSES: '40',
GITNEXUS_MAX_PROCESS_BRANCHING: '2',
GITNEXUS_MAX_PROCESS_TRACE_DEPTH: '8',
GITNEXUS_MAX_ENTRY_POINT_CANDIDATES: '400',
},
);
expect(resolved.maxProcesses).toBe(40);
expect(resolved.maxProcessBranching).toBe(2);
expect(resolved.maxProcessTraceDepth).toBe(8);
expect(resolved.maxEntryPointCandidates).toBe(400);
});
it('warns and falls back on invalid env (AE5)', () => {
const invalid: Array<[string, string]> = [];
const zero = resolveProcessDetectionBudget({}, { GITNEXUS_MAX_PROCESSES: '0' }, (knob, raw) =>
invalid.push([knob, raw]),
);
const garbage = resolveProcessDetectionBudget(
{},
{ GITNEXUS_MAX_PROCESSES: 'abc' },
(knob, raw) => invalid.push([knob, raw]),
);
expect(zero.maxProcesses).toBeUndefined();
expect(garbage.maxProcesses).toBeUndefined();
expect(invalid).toEqual([
['GITNEXUS_MAX_PROCESSES', '0'],
['GITNEXUS_MAX_PROCESSES', 'abc'],
]);
});
it('replaces the dynamic formula only when maxProcesses is explicit', () => {
const unset = buildProcessDetectionPhaseConfig(
resolveProcessDetectionBudget({}, {}),
1000,
(n) => Math.max(20, Math.round(n / 10)),
);
const explicit = buildProcessDetectionPhaseConfig(
resolveProcessDetectionBudget({ maxProcesses: 5 }, {}),
1000,
(n) => Math.max(20, Math.round(n / 10)),
);
expect(unset.maxProcesses).toBe(100);
expect(explicit.maxProcesses).toBe(5);
expect(explicit.minSteps).toBe(3);
});
});
describe('processDetectionBudgetMismatch (KTD4)', () => {
const defaults = resolveProcessDetectionBudget({}, {});
it('matches a missing stamp against default/dynamic knobs', () => {
expect(processDetectionBudgetMismatch(undefined, defaults)).toBe(false);
});
it('mismatches a missing stamp when any override is set', () => {
expect(
processDetectionBudgetMismatch(
undefined,
resolveProcessDetectionBudget({ maxEntryPointCandidates: 400 }, {}),
),
).toBe(true);
});
it('mismatches when a present stamp differs', () => {
const recorded = toProcessDetectionStamp(defaults);
expect(
processDetectionBudgetMismatch({ ...recorded, maxEntryPointCandidates: 400 }, defaults),
).toBe(true);
});
it('mismatches explicit maxProcesses vs later dynamic even when the integer equals the formula', () => {
const explicit = resolveProcessDetectionBudget({ maxProcesses: 100 }, {});
expect(processDetectionBudgetMismatch(toProcessDetectionStamp(explicit), defaults)).toBe(true);
expect(toProcessDetectionStamp(explicit).maxProcesses).toBe(100);
expect(toProcessDetectionStamp(defaults).maxProcesses).toBe(null);
});
it('matches an identical present stamp', () => {
const resolved = resolveProcessDetectionBudget(
{ maxProcesses: 25, maxEntryPointCandidates: 400 },
{},
);
expect(processDetectionBudgetMismatch(toProcessDetectionStamp(resolved), resolved)).toBe(false);
});
it('mismatches an uncertified stamp even when the numeric fields match defaults', () => {
const recorded = uncertifyProcessDetectionStamp(toProcessDetectionStamp(defaults));
expect(recorded.uncertified).toBe(true);
expect(recorded.maxProcesses).toBe(null);
expect(processDetectionBudgetMismatch(recorded, defaults)).toBe(true);
expect(processDetectionBudgetMismatch(toProcessDetectionStamp(defaults), defaults)).toBe(false);
});
});
describe('warning copy and banner', () => {
it('maps loud counters to the matching knobs and names the pre-entry trace gate', () => {
const limits = processDetectionEffectiveLimits(20, resolveProcessDetectionBudget({}, {}));
expect(limits.maxProcessTraces).toBe(40);
expect(
formatWholeFlowsMissingRemedies(
{ entryPointCandidatesDropped: 210, entryPointsUnexplored: 0, processesDropped: 0 },
limits,
410,
),
).toContain('--max-entry-point-candidates');
expect(
formatWholeFlowsMissingRemedies(
{ entryPointCandidatesDropped: 210, entryPointsUnexplored: 0, processesDropped: 0 },
limits,
410,
),
).not.toContain('--max-process-branching');
expect(
formatWholeFlowsMissingRemedies(
{ entryPointCandidatesDropped: 0, entryPointsUnexplored: 3, processesDropped: 1 },
limits,
10,
),
).toContain('--max-processes');
expect(
formatWholeFlowsMissingRemedies(
{ entryPointCandidatesDropped: 0, entryPointsUnexplored: 3, processesDropped: 1 },
limits,
10,
),
).toContain('40');
expect(
formatWholeFlowsMissingRemedies(
{ entryPointCandidatesDropped: 0, entryPointsUnexplored: 3, processesDropped: 1 },
limits,
10,
),
).toContain('next entry is skipped');
});
it('prints a banner only when an override is active', () => {
expect(formatProcessDetectionBudgetBanner(resolveProcessDetectionBudget({}, {}))).toBeNull();
expect(
formatProcessDetectionBudgetBanner(resolveProcessDetectionBudget({ maxProcesses: 25 }, {})),
).toContain('maxProcesses=25');
expect(
formatProcessDetectionBudgetBanner(
resolveProcessDetectionBudget({ maxProcessBranching: 6 }, {}),
),
).toContain('maxProcesses=dynamic (max(20, round(symbols/10)))');
});
it('parses CLI/rc numeric strings without treating 0 as unlimited', () => {
const parsed = parseProcessDetectionBudgetStrings({
maxProcesses: '25',
maxProcessBranching: '0',
});
expect(parsed).toEqual({ maxProcesses: 25 });
});
});