mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
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 (#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>
378 lines
16 KiB
TypeScript
378 lines
16 KiB
TypeScript
/**
|
||
* The processes phase's SINK WIRING, exercised on its success path (#2896).
|
||
*
|
||
* `processesPhase` reads `allFetchCalls` / `allORMQueries` off the parse output
|
||
* to build the R3-6 sink set, wrapped in a `try/catch` that falls open to "no
|
||
* sinks". Every other phase-level test omits `parse` from its deps map, so all
|
||
* of them take the CATCH branch — the success path had no coverage at all.
|
||
*
|
||
* That matters because `getPhaseOutput` is a raw `as T` cast. If the field names
|
||
* on `ParseOutput` ever drift, the phase reads nothing, detects zero sinks, and
|
||
* every existing test still passes, because zero sinks is exactly what they
|
||
* already assert. The wiring could break silently and in complete silence.
|
||
*
|
||
* So this asserts the thing only the success path can produce: a flow that ENDS
|
||
* at the sink, while a longer chain continues past it. Without the sink set that
|
||
* prefix is subsumed and only the long chain survives.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||
import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js';
|
||
import { processesPhase } from '../../src/core/ingestion/pipeline-phases/processes.js';
|
||
import type { ProcessesOutput } from '../../src/core/ingestion/pipeline-phases/processes.js';
|
||
import type {
|
||
PhaseResult,
|
||
PipelineContext,
|
||
} from '../../src/core/ingestion/pipeline-phases/types.js';
|
||
import type { PipelineOptions } from '../../src/core/ingestion/pipeline.js';
|
||
import type { KnowledgeGraph } from '../../src/core/graph/types.js';
|
||
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
|
||
|
||
function makeCtx(graph: KnowledgeGraph, options?: PipelineOptions): PipelineContext {
|
||
return { repoPath: '/tmp/repo', graph, onProgress: () => {}, pipelineStart: 0, options };
|
||
}
|
||
|
||
function phaseResult<T>(phaseName: string, output: T): PhaseResult<T> {
|
||
return { phaseName, output, durationMs: 0 };
|
||
}
|
||
|
||
const FILE = 'src/orders.ts';
|
||
|
||
function addNode(graph: KnowledgeGraph, id: string, label: NodeLabel, name: string, line: number) {
|
||
graph.addNode({
|
||
id,
|
||
label,
|
||
properties: {
|
||
name,
|
||
filePath: FILE,
|
||
startLine: line,
|
||
endLine: line + 4,
|
||
isExported: true,
|
||
content: '',
|
||
},
|
||
} satisfies GraphNode);
|
||
}
|
||
|
||
function addCall(graph: KnowledgeGraph, from: string, to: string): void {
|
||
graph.addRelationship({
|
||
id: `rel:${from}->${to}`,
|
||
sourceId: from,
|
||
targetId: to,
|
||
type: 'CALLS',
|
||
confidence: 1,
|
||
reason: 'test',
|
||
});
|
||
}
|
||
|
||
/**
|
||
* `scan -> score -> placeOrder -> formatDate`, where `placeOrder` performs the
|
||
* outward call. The business flow ends at `placeOrder`; the chain runs on into a
|
||
* helper. Both must exist as processes — that is the whole point of R3-6.
|
||
*/
|
||
function buildGraph(): KnowledgeGraph {
|
||
const graph = createKnowledgeGraph();
|
||
addNode(graph, 'File:' + FILE, 'File', 'orders.ts', 1);
|
||
addNode(graph, 'Function:scan', 'Function', 'scan', 1);
|
||
addNode(graph, 'Function:score', 'Function', 'score', 10);
|
||
addNode(graph, 'Function:placeOrder', 'Function', 'placeOrder', 20);
|
||
addNode(graph, 'Function:formatDate', 'Function', 'formatDate', 30);
|
||
addCall(graph, 'Function:scan', 'Function:score');
|
||
addCall(graph, 'Function:score', 'Function:placeOrder');
|
||
addCall(graph, 'Function:placeOrder', 'Function:formatDate');
|
||
return graph;
|
||
}
|
||
|
||
const baseDeps = (): Map<string, PhaseResult<unknown>> =>
|
||
new Map<string, PhaseResult<unknown>>([
|
||
['structure', phaseResult('structure', { totalFiles: 1 })],
|
||
['communities', phaseResult('communities', { communityResult: { memberships: [] } })],
|
||
['routes', phaseResult('routes', { routeRegistry: new Map() })],
|
||
['tools', phaseResult('tools', { toolDefs: [] })],
|
||
]);
|
||
|
||
/** A fetch site INSIDE `placeOrder`, which is what makes it a sink. */
|
||
const parseWithSinks = (): PhaseResult<unknown> =>
|
||
phaseResult('parse', {
|
||
allFetchCalls: [{ filePath: FILE, lineNumber: 22 }],
|
||
allORMQueries: [],
|
||
});
|
||
|
||
const terminalsOf = (graph: KnowledgeGraph): string[] => {
|
||
const out: string[] = [];
|
||
for (const node of graph.iterNodes()) {
|
||
if (node.label === 'Process') out.push(String(node.properties.terminalId));
|
||
}
|
||
return out;
|
||
};
|
||
|
||
describe('processes phase — parse-output sink wiring (#2896)', () => {
|
||
it('declares `parse` as a dependency', () => {
|
||
// The read and the declaration must not diverge: dropping the dep would
|
||
// make `getPhaseOutput` throw into the fail-open catch on every run, and
|
||
// every other test would still pass.
|
||
expect(processesPhase.deps).toContain('parse');
|
||
});
|
||
|
||
it('reads the parse output and produces a SINK-TERMINATED flow', async () => {
|
||
const graph = buildGraph();
|
||
const deps = baseDeps();
|
||
deps.set('parse', parseWithSinks());
|
||
|
||
await processesPhase.execute(makeCtx(graph), deps);
|
||
|
||
// `placeOrder` is a terminal even though the chain continues into
|
||
// `formatDate` — only the sink set can produce that.
|
||
expect(terminalsOf(graph)).toContain('Function:placeOrder');
|
||
});
|
||
|
||
it('the same graph WITHOUT parse yields no sink-terminated flow', async () => {
|
||
// The control. Without it the assertion above could pass for an unrelated
|
||
// reason — this is the fail-open branch every other phase test takes, and it
|
||
// is what makes the difference attributable to the wiring.
|
||
const graph = buildGraph();
|
||
|
||
await processesPhase.execute(makeCtx(graph), baseDeps());
|
||
|
||
expect(terminalsOf(graph)).not.toContain('Function:placeOrder');
|
||
});
|
||
|
||
it('survives a parse output whose sink fields are absent', async () => {
|
||
// Fail-open is deliberate: a pipeline composed without those outputs should
|
||
// detect no sinks rather than lose every process.
|
||
const graph = buildGraph();
|
||
const deps = baseDeps();
|
||
deps.set('parse', phaseResult('parse', {}));
|
||
|
||
await processesPhase.execute(makeCtx(graph), deps);
|
||
|
||
expect(terminalsOf(graph).length).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* WHICH ceilings are loud (#2899 follow-up).
|
||
*
|
||
* The truncation disclosure landed as an ungated `logger.warn` on
|
||
* `truncation.truncated`, and this phase overrides only `maxProcesses` — so at
|
||
* the shipped defaults (`maxBranching: 4`, `maxTraceDepth: 10`, per-entry trace
|
||
* budget 12) it fired for any function with five callees and any chain deeper
|
||
* than ten, i.e. on every non-trivial repository, every run. A warning that
|
||
* always fires is a warning nobody reads.
|
||
*
|
||
* The split asserted here is the one `ProcessTruncationStats` already writes
|
||
* down: a ceiling that removes WHOLE FLOWS from the report warns; a ceiling that
|
||
* only makes a reported flow shorter than the code path it describes goes to
|
||
* debug. Both fixtures are truncated — what differs is which kind.
|
||
*/
|
||
describe('processes phase — truncation is disclosed proportionately (#2899)', () => {
|
||
const addFn = (graph: KnowledgeGraph, id: string): void => {
|
||
graph.addNode({
|
||
id,
|
||
label: 'Function',
|
||
properties: { name: id.split(':')[1], filePath: 'src/a.ts', startLine: 1, endLine: 2 },
|
||
});
|
||
};
|
||
const addCallEdge = (graph: KnowledgeGraph, from: string, to: string): void => {
|
||
graph.addRelationship({
|
||
id: `rel:${from}->${to}`,
|
||
sourceId: from,
|
||
targetId: to,
|
||
type: 'CALLS',
|
||
confidence: 1,
|
||
reason: 'test',
|
||
});
|
||
};
|
||
|
||
/**
|
||
* Trips ONLY the shape ceilings: a 13-long chain (traces cut at
|
||
* `maxTraceDepth`) and a five-way fan-out (a callee skipped at
|
||
* `maxBranching`). Every flow found is still in the report — none of the four
|
||
* surviving traces was dropped, and the phase's own `maxProcesses` floor of 20
|
||
* is well clear of them.
|
||
*/
|
||
const shortenedOnly = (): KnowledgeGraph => {
|
||
const graph = createKnowledgeGraph();
|
||
for (let i = 0; i < 13; i++) addFn(graph, `func:c${i}`);
|
||
for (let i = 0; i < 12; i++) addCallEdge(graph, `func:c${i}`, `func:c${i + 1}`);
|
||
addFn(graph, 'func:fanout');
|
||
for (let i = 0; i < 5; i++) {
|
||
addFn(graph, `func:leaf${i}`);
|
||
addCallEdge(graph, 'func:fanout', `func:leaf${i}`);
|
||
}
|
||
return graph;
|
||
};
|
||
|
||
/**
|
||
* Trips ONLY the whole-flow ceilings: 40 independent three-step chains against
|
||
* a `maxProcesses` of 20 (the floor `computeDynamicMaxProcesses` gives 120
|
||
* symbols), so half the deduplicated flows are dropped outright and the trace
|
||
* quota stops the loop with entry points still unvisited. Nothing here is
|
||
* deep enough or wide enough to hit `maxTraceDepth` or `maxBranching`.
|
||
*/
|
||
const flowsMissing = (): KnowledgeGraph => {
|
||
const graph = createKnowledgeGraph();
|
||
for (let c = 0; c < 40; c++) {
|
||
for (let i = 0; i < 3; i++) addFn(graph, `func:p${c}_${i}`);
|
||
for (let i = 0; i < 2; i++) addCallEdge(graph, `func:p${c}_${i}`, `func:p${c}_${i + 1}`);
|
||
}
|
||
return graph;
|
||
};
|
||
|
||
const runCaptured = async (
|
||
graph: KnowledgeGraph,
|
||
options?: PipelineOptions,
|
||
): Promise<{ output: ProcessesOutput; records: ReturnType<LoggerCapture['records']> }> => {
|
||
// Captured at `debug` so an ABSENT warn can be distinguished from a silent
|
||
// phase: the debug line has to be there instead.
|
||
const capture = _captureLogger('debug');
|
||
try {
|
||
const output = (await processesPhase.execute(
|
||
makeCtx(graph, options),
|
||
baseDeps(),
|
||
)) as ProcessesOutput;
|
||
return { output, records: capture.records() };
|
||
} finally {
|
||
capture.restore();
|
||
}
|
||
};
|
||
|
||
const PROCESS_LINES = /^\[processes\] /;
|
||
|
||
it('does NOT warn when the caps only made flows shorter', async () => {
|
||
const { output, records } = await runCaptured(shortenedOnly());
|
||
const { truncation } = output.processResult.stats;
|
||
|
||
// The fixture is genuinely truncated — this is not a "nothing happened" pass.
|
||
expect(truncation.truncated).toBe(true);
|
||
expect(truncation.tracesDepthCapped).toBeGreaterThan(0);
|
||
expect(truncation.calleesDropped).toBeGreaterThan(0);
|
||
// ...and truncated in NO other way, so the assertions below are attributable.
|
||
expect(truncation.entryPointCandidatesDropped).toBe(0);
|
||
expect(truncation.entryPointsUnexplored).toBe(0);
|
||
expect(truncation.processesDropped).toBe(0);
|
||
|
||
const lines = records.filter((r) => PROCESS_LINES.test(String(r.msg)));
|
||
expect(lines.map((r) => r.level)).toEqual([20]); // debug, not warn
|
||
expect(String(lines[0]?.msg)).toContain('shorter than the code path');
|
||
});
|
||
|
||
it('DOES warn when whole flows are missing from the report', async () => {
|
||
const { output, records } = await runCaptured(flowsMissing());
|
||
const { truncation } = output.processResult.stats;
|
||
|
||
expect(truncation.processesDropped).toBeGreaterThan(0);
|
||
expect(truncation.entryPointsUnexplored).toBeGreaterThan(0);
|
||
// Neither shape ceiling fired here, so the warn is attributable to the
|
||
// whole-flow counters and not to a chain that merely ran long.
|
||
expect(truncation.tracesDepthCapped).toBe(0);
|
||
expect(truncation.calleesDropped).toBe(0);
|
||
|
||
const lines = records.filter((r) => PROCESS_LINES.test(String(r.msg)));
|
||
expect(lines.map((r) => r.level)).toEqual([40]); // warn
|
||
expect(String(lines[0]?.msg)).toContain('whole flows are MISSING');
|
||
});
|
||
|
||
it('says nothing at all when no ceiling fired', async () => {
|
||
// The control for both: the phase must not narrate an untruncated run.
|
||
const graph = createKnowledgeGraph();
|
||
for (let i = 0; i < 3; i++) addFn(graph, `func:q${i}`);
|
||
for (let i = 0; i < 2; i++) addCallEdge(graph, `func:q${i}`, `func:q${i + 1}`);
|
||
|
||
const { output, records } = await runCaptured(graph);
|
||
|
||
expect(output.processResult.processes.length).toBeGreaterThan(0);
|
||
expect(output.processResult.stats.truncation.truncated).toBe(false);
|
||
expect(records.filter((r) => PROCESS_LINES.test(String(r.msg)))).toEqual([]);
|
||
});
|
||
|
||
it('reports the entry-point candidate cap in the warn payload', async () => {
|
||
// The dominant ceiling on any real repository, and the one that stays in the
|
||
// loud set precisely because it is the only counter that grows with repo
|
||
// size — `entryPointsUnexplored` and `processesDropped` can only fire while
|
||
// `maxProcesses` is small enough to bind.
|
||
const graph = createKnowledgeGraph();
|
||
for (let c = 0; c < 205; c++) {
|
||
for (let i = 0; i < 3; i++) addFn(graph, `func:m${c}_${i}`);
|
||
for (let i = 0; i < 2; i++) addCallEdge(graph, `func:m${c}_${i}`, `func:m${c}_${i + 1}`);
|
||
}
|
||
|
||
const { output, records } = await runCaptured(graph);
|
||
|
||
expect(output.processResult.stats.truncation.entryPointCandidatesDropped).toBe(210);
|
||
const lines = records.filter((r) => PROCESS_LINES.test(String(r.msg)));
|
||
expect(lines.map((r) => r.level)).toEqual([40]);
|
||
expect(String(lines[0]?.msg)).toContain('210 of 410 candidate entry point(s) never ranked in');
|
||
expect(String(lines[0]?.msg)).toContain('--max-entry-point-candidates');
|
||
expect(String(lines[0]?.msg)).not.toContain('--max-process-branching');
|
||
expect(lines[0]?.effectiveLimits).toEqual(
|
||
expect.objectContaining({
|
||
maxEntryPointCandidates: 200,
|
||
maxProcessTraces: expect.any(Number),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('honors an explicit maxProcesses instead of the dynamic formula (#3313)', async () => {
|
||
const { output } = await runCaptured(flowsMissing(), { maxProcesses: 40 });
|
||
expect(output.processResult.stats.truncation.processesDropped).toBe(0);
|
||
expect(output.processResult.stats.truncation.entryPointsUnexplored).toBe(0);
|
||
});
|
||
|
||
it('clears the entry-point drop when the candidate cap is raised (#3313 AE2)', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
for (let c = 0; c < 205; c++) {
|
||
for (let i = 0; i < 3; i++) addFn(graph, `func:r${c}_${i}`);
|
||
for (let i = 0; i < 2; i++) addCallEdge(graph, `func:r${c}_${i}`, `func:r${c}_${i + 1}`);
|
||
}
|
||
const { output } = await runCaptured(graph, { maxEntryPointCandidates: 410 });
|
||
expect(output.processResult.stats.truncation.entryPointCandidatesDropped).toBe(0);
|
||
});
|
||
|
||
it('still warns when only maxProcesses is raised on a 410-candidate fixture (#3313 AE2)', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
for (let c = 0; c < 205; c++) {
|
||
for (let i = 0; i < 3; i++) addFn(graph, `func:s${c}_${i}`);
|
||
for (let i = 0; i < 2; i++) addCallEdge(graph, `func:s${c}_${i}`, `func:s${c}_${i + 1}`);
|
||
}
|
||
const { output, records } = await runCaptured(graph, { maxProcesses: 1000 });
|
||
expect(output.processResult.stats.truncation.entryPointCandidatesDropped).toBe(210);
|
||
const lines = records.filter((r) => PROCESS_LINES.test(String(r.msg)));
|
||
expect(lines.map((r) => r.level)).toEqual([40]);
|
||
});
|
||
|
||
it('increases process count when both binders are raised (#3313 AE6)', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
for (let c = 0; c < 210; c++) {
|
||
for (let i = 0; i < 3; i++) addFn(graph, `func:ae6_${c}_${i}`);
|
||
for (let i = 0; i < 2; i++)
|
||
addCallEdge(graph, `func:ae6_${c}_${i}`, `func:ae6_${c}_${i + 1}`);
|
||
}
|
||
// 210 chains × 3 = 630 symbols. Dynamic maxProcesses needs ≥ 210, so pad.
|
||
for (let i = 0; i < 1470; i++) addFn(graph, `func:pad_${i}`);
|
||
|
||
const baseline = await runCaptured(graph);
|
||
const raised = await runCaptured(graph, {
|
||
maxEntryPointCandidates: 420,
|
||
maxProcesses: 300,
|
||
});
|
||
expect(
|
||
baseline.output.processResult.stats.truncation.entryPointCandidatesDropped,
|
||
).toBeGreaterThan(0);
|
||
expect(raised.output.processResult.stats.truncation.entryPointCandidatesDropped).toBe(0);
|
||
expect(raised.output.processResult.processes.length).toBeGreaterThan(
|
||
baseline.output.processResult.processes.length,
|
||
);
|
||
});
|
||
|
||
it('keeps shape-only caps at debug when branching or depth is raised (#3313 AE7)', async () => {
|
||
const { output, records } = await runCaptured(shortenedOnly(), {
|
||
maxProcessBranching: 8,
|
||
maxProcessTraceDepth: 20,
|
||
});
|
||
expect(output.processResult.stats.truncation.calleesDropped).toBe(0);
|
||
expect(output.processResult.stats.truncation.tracesDepthCapped).toBe(0);
|
||
const lines = records.filter((r) => PROCESS_LINES.test(String(r.msg)));
|
||
expect(lines.some((r) => r.level === 40)).toBe(false);
|
||
expect(lines.some((r) => String(r.msg).includes('whole flows are MISSING'))).toBe(false);
|
||
});
|
||
});
|