GitNexus/gitnexus/test/unit/process-processor.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

1439 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, vi } from 'vitest';
import {
processProcesses,
traceFromEntryPoint,
buildSinkFunctionSet,
deduplicateTraces,
type ProcessDetectionConfig,
} from '../../src/core/ingestion/process-processor.js';
import { computeDynamicMaxProcesses } from '../../src/core/ingestion/pipeline-phases/processes.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import type { CommunityMembership } from '../../src/core/ingestion/community-processor.js';
describe('processProcesses', () => {
it('detects no processes in empty graph', async () => {
const graph = createKnowledgeGraph();
const result = await processProcesses(graph, []);
expect(result.processes).toHaveLength(0);
expect(result.steps).toHaveLength(0);
expect(result.stats.totalProcesses).toBe(0);
expect(result.stats.entryPointsFound).toBe(0);
expect(result.stats.avgStepCount).toBe(0);
});
it('detects no processes when there are no CALLS relationships', async () => {
const graph = createKnowledgeGraph();
graph.addNode({
id: 'func:main',
label: 'Function',
properties: {
name: 'main',
filePath: 'src/index.ts',
startLine: 1,
endLine: 10,
isExported: true,
},
});
const result = await processProcesses(graph, []);
expect(result.processes).toHaveLength(0);
});
it('detects a simple 3-step process with correct structure', async () => {
const graph = createKnowledgeGraph();
// Create 3 functions in a chain
graph.addNode({
id: 'func:handleRequest',
label: 'Function',
properties: {
name: 'handleRequest',
filePath: 'src/handler.ts',
startLine: 1,
endLine: 10,
isExported: true,
},
});
graph.addNode({
id: 'func:validateInput',
label: 'Function',
properties: {
name: 'validateInput',
filePath: 'src/validator.ts',
startLine: 1,
endLine: 5,
isExported: true,
},
});
graph.addNode({
id: 'func:saveToDb',
label: 'Function',
properties: {
name: 'saveToDb',
filePath: 'src/db.ts',
startLine: 1,
endLine: 8,
isExported: true,
},
});
// handleRequest -> validateInput -> saveToDb
graph.addRelationship({
id: 'call:1',
sourceId: 'func:handleRequest',
targetId: 'func:validateInput',
type: 'CALLS',
confidence: 0.9,
reason: 'import-resolved',
});
graph.addRelationship({
id: 'call:2',
sourceId: 'func:validateInput',
targetId: 'func:saveToDb',
type: 'CALLS',
confidence: 0.9,
reason: 'import-resolved',
});
const memberships: CommunityMembership[] = [
{ nodeId: 'func:handleRequest', communityId: 'community:0' },
{ nodeId: 'func:validateInput', communityId: 'community:0' },
{ nodeId: 'func:saveToDb', communityId: 'community:0' },
];
const result = await processProcesses(graph, memberships);
// Must detect at least one process
expect(result.processes.length).toBeGreaterThan(0);
// Find the process starting from handleRequest
const process = result.processes.find((p) => p.entryPointId === 'func:handleRequest');
expect(process).toBeDefined();
expect(process!.stepCount).toBe(3);
expect(process!.entryPointId).toBe('func:handleRequest');
expect(process!.terminalId).toBe('func:saveToDb');
expect(process!.processType).toBe('intra_community');
expect(process!.communities).toEqual(['community:0']);
// Verify trace order: entry -> middle -> terminal
expect(process!.trace).toEqual(['func:handleRequest', 'func:validateInput', 'func:saveToDb']);
// Verify steps are 1-indexed and in correct order
const processSteps = result.steps.filter((s) => s.processId === process!.id);
expect(processSteps).toHaveLength(3);
expect(processSteps[0]).toEqual(
expect.objectContaining({ nodeId: 'func:handleRequest', step: 1 }),
);
expect(processSteps[1]).toEqual(
expect.objectContaining({ nodeId: 'func:validateInput', step: 2 }),
);
expect(processSteps[2]).toEqual(expect.objectContaining({ nodeId: 'func:saveToDb', step: 3 }));
// Verify label is generated from entry and terminal names
expect(process!.heuristicLabel).toContain('HandleRequest');
expect(process!.heuristicLabel).toContain('SaveToDb');
// Stats should reflect the detected processes
expect(result.stats.totalProcesses).toBe(result.processes.length);
expect(result.stats.entryPointsFound).toBeGreaterThan(0);
});
it('respects maxTraceDepth config', async () => {
const graph = createKnowledgeGraph();
// Create a long chain: f0 -> f1 -> f2 -> f3 -> f4
for (let i = 0; i < 5; i++) {
graph.addNode({
id: `func:f${i}`,
label: 'Function',
properties: {
name: `f${i}`,
filePath: `src/f${i}.ts`,
startLine: 1,
endLine: 5,
isExported: true,
},
});
}
for (let i = 0; i < 4; i++) {
graph.addRelationship({
id: `call:${i}`,
sourceId: `func:f${i}`,
targetId: `func:f${i + 1}`,
type: 'CALLS',
confidence: 0.9,
reason: '',
});
}
const memberships: CommunityMembership[] = Array.from({ length: 5 }, (_, i) => ({
nodeId: `func:f${i}`,
communityId: 'community:0',
}));
// Limit to 3 steps max depth
const config: Partial<ProcessDetectionConfig> = { maxTraceDepth: 3 };
const result = await processProcesses(graph, memberships, undefined, config);
// Should still find processes, but each trace should be at most maxTraceDepth steps
expect(result.processes.length).toBeGreaterThan(0);
for (const process of result.processes) {
expect(process.stepCount).toBeLessThanOrEqual(3);
}
});
it('detects cross_community processes', async () => {
const graph = createKnowledgeGraph();
graph.addNode({
id: 'func:apiHandler',
label: 'Function',
properties: {
name: 'apiHandler',
filePath: 'src/api/handler.ts',
startLine: 1,
endLine: 10,
isExported: true,
},
});
graph.addNode({
id: 'func:dbQuery',
label: 'Function',
properties: {
name: 'dbQuery',
filePath: 'src/db/query.ts',
startLine: 1,
endLine: 5,
isExported: true,
},
});
graph.addNode({
id: 'func:formatResponse',
label: 'Function',
properties: {
name: 'formatResponse',
filePath: 'src/api/format.ts',
startLine: 1,
endLine: 5,
isExported: true,
},
});
// apiHandler -> dbQuery (cross community), apiHandler -> formatResponse (same community)
graph.addRelationship({
id: 'call:1',
sourceId: 'func:apiHandler',
targetId: 'func:dbQuery',
type: 'CALLS',
confidence: 0.9,
reason: '',
});
graph.addRelationship({
id: 'call:2',
sourceId: 'func:dbQuery',
targetId: 'func:formatResponse',
type: 'CALLS',
confidence: 0.9,
reason: '',
});
// Put them in different communities
const memberships: CommunityMembership[] = [
{ nodeId: 'func:apiHandler', communityId: 'community:api' },
{ nodeId: 'func:dbQuery', communityId: 'community:db' },
{ nodeId: 'func:formatResponse', communityId: 'community:api' },
];
const result = await processProcesses(graph, memberships);
// Must find at least one process
expect(result.processes.length).toBeGreaterThan(0);
// The process from apiHandler should be cross_community (touches api + db communities)
const crossProcess = result.processes.find((p) => p.entryPointId === 'func:apiHandler');
expect(crossProcess).toBeDefined();
expect(crossProcess!.processType).toBe('cross_community');
expect(crossProcess!.communities.length).toBeGreaterThan(1);
expect(crossProcess!.communities).toContain('community:api');
expect(crossProcess!.communities).toContain('community:db');
// Stats should count cross-community
expect(result.stats.crossCommunityCount).toBeGreaterThan(0);
});
it('excludes test files from entry points', async () => {
const graph = createKnowledgeGraph();
// Test file function
graph.addNode({
id: 'func:testMain',
label: 'Function',
properties: {
name: 'testMain',
filePath: 'test/unit/main.test.ts',
startLine: 1,
endLine: 10,
isExported: true,
},
});
graph.addNode({
id: 'func:helper',
label: 'Function',
properties: {
name: 'helper',
filePath: 'src/helper.ts',
startLine: 1,
endLine: 5,
isExported: true,
},
});
graph.addRelationship({
id: 'call:1',
sourceId: 'func:testMain',
targetId: 'func:helper',
type: 'CALLS',
confidence: 0.9,
reason: '',
});
const result = await processProcesses(graph, []);
// Test files should not be used as entry points
const testProcess = result.processes.find((p) => p.entryPointId === 'func:testMain');
expect(testProcess).toBeUndefined();
});
it('filters out low-confidence calls (below 0.5)', async () => {
const graph = createKnowledgeGraph();
graph.addNode({
id: 'func:a',
label: 'Function',
properties: { name: 'a', filePath: 'src/a.ts', startLine: 1, endLine: 5, isExported: true },
});
graph.addNode({
id: 'func:b',
label: 'Function',
properties: { name: 'b', filePath: 'src/b.ts', startLine: 1, endLine: 5, isExported: true },
});
graph.addNode({
id: 'func:c',
label: 'Function',
properties: { name: 'c', filePath: 'src/c.ts', startLine: 1, endLine: 5, isExported: true },
});
// a -> b with low confidence (fuzzy-global ambiguous), a -> c with high confidence
graph.addRelationship({
id: 'call:1',
sourceId: 'func:a',
targetId: 'func:b',
type: 'CALLS',
confidence: 0.3,
reason: 'fuzzy-global',
});
graph.addRelationship({
id: 'call:2',
sourceId: 'func:a',
targetId: 'func:c',
type: 'CALLS',
confidence: 0.9,
reason: 'import-resolved',
});
const result = await processProcesses(graph, []);
// No process should include func:b since the edge has confidence < 0.5 (MIN_TRACE_CONFIDENCE)
for (const process of result.processes) {
expect(process.trace).not.toContain('func:b');
}
});
it('handles cycles without infinite loops', async () => {
const graph = createKnowledgeGraph();
graph.addNode({
id: 'func:a',
label: 'Function',
properties: {
name: 'processItem',
filePath: 'src/a.ts',
startLine: 1,
endLine: 5,
isExported: true,
},
});
graph.addNode({
id: 'func:b',
label: 'Function',
properties: {
name: 'validate',
filePath: 'src/b.ts',
startLine: 1,
endLine: 5,
isExported: true,
},
});
graph.addNode({
id: 'func:c',
label: 'Function',
properties: {
name: 'retry',
filePath: 'src/c.ts',
startLine: 1,
endLine: 5,
isExported: true,
},
});
// a -> b -> c -> a (cycle)
graph.addRelationship({
id: 'call:1',
sourceId: 'func:a',
targetId: 'func:b',
type: 'CALLS',
confidence: 0.9,
reason: '',
});
graph.addRelationship({
id: 'call:2',
sourceId: 'func:b',
targetId: 'func:c',
type: 'CALLS',
confidence: 0.9,
reason: '',
});
graph.addRelationship({
id: 'call:3',
sourceId: 'func:c',
targetId: 'func:a',
type: 'CALLS',
confidence: 0.9,
reason: '',
});
const memberships: CommunityMembership[] = [
{ nodeId: 'func:a', communityId: 'community:0' },
{ nodeId: 'func:b', communityId: 'community:0' },
{ nodeId: 'func:c', communityId: 'community:0' },
];
// Should complete without hanging, and traces should not repeat nodes
const result = await processProcesses(graph, memberships);
for (const process of result.processes) {
const uniqueNodes = new Set(process.trace);
expect(uniqueNodes.size).toBe(process.trace.length);
}
});
it('respects minSteps default (3) — rejects 2-step traces', async () => {
const graph = createKnowledgeGraph();
// Only 2 functions: a -> b (2 steps, below default minSteps of 3)
graph.addNode({
id: 'func:caller',
label: 'Function',
properties: {
name: 'caller',
filePath: 'src/caller.ts',
startLine: 1,
endLine: 5,
isExported: true,
},
});
graph.addNode({
id: 'func:callee',
label: 'Function',
properties: {
name: 'callee',
filePath: 'src/callee.ts',
startLine: 1,
endLine: 5,
isExported: true,
},
});
graph.addRelationship({
id: 'call:1',
sourceId: 'func:caller',
targetId: 'func:callee',
type: 'CALLS',
confidence: 0.9,
reason: '',
});
const result = await processProcesses(graph, []);
// Default minSteps is 3, so a 2-step trace (caller -> callee) should be rejected
expect(result.processes).toHaveLength(0);
});
it('calls progress callback with messages', async () => {
const graph = createKnowledgeGraph();
const onProgress = vi.fn();
await processProcesses(graph, [], onProgress);
expect(onProgress).toHaveBeenCalled();
// Verify callback receives (message: string, progress: number)
const [message, progress] = onProgress.mock.calls[0];
expect(typeof message).toBe('string');
expect(typeof progress).toBe('number');
expect(progress).toBeGreaterThanOrEqual(0);
expect(progress).toBeLessThanOrEqual(100);
});
it('limits output to maxProcesses', async () => {
const graph = createKnowledgeGraph();
// Create many independent 3-step chains to generate many processes
for (let chain = 0; chain < 10; chain++) {
for (let step = 0; step < 3; step++) {
graph.addNode({
id: `func:chain${chain}_f${step}`,
label: 'Function',
properties: {
name: `chain${chain}_f${step}`,
filePath: `src/chain${chain}/f${step}.ts`,
startLine: 1,
endLine: 5,
isExported: true,
},
});
}
for (let step = 0; step < 2; step++) {
graph.addRelationship({
id: `call:chain${chain}_${step}`,
sourceId: `func:chain${chain}_f${step}`,
targetId: `func:chain${chain}_f${step + 1}`,
type: 'CALLS',
confidence: 0.9,
reason: '',
});
}
}
const memberships: CommunityMembership[] = [];
for (let chain = 0; chain < 10; chain++) {
for (let step = 0; step < 3; step++) {
memberships.push({ nodeId: `func:chain${chain}_f${step}`, communityId: 'community:0' });
}
}
const config: Partial<ProcessDetectionConfig> = { maxProcesses: 3 };
const result = await processProcesses(graph, memberships, undefined, config);
expect(result.processes.length).toBeLessThanOrEqual(3);
expect(result.stats.totalProcesses).toBeLessThanOrEqual(3);
});
// Regression for #2198: the processesPhase dynamic sizing used to cap at
// Math.min(300, symbolCount/10). On large repos (>3000 symbols) that silently
// truncated the process index. The cap was removed by extracting
// computeDynamicMaxProcesses() — this test exercises the helper directly
// so it fails if someone reintroduces the 300 ceiling.
describe('computeDynamicMaxProcesses (#2198)', () => {
it('returns at least the floor of 20 for tiny repos', () => {
expect(computeDynamicMaxProcesses(0)).toBe(20);
expect(computeDynamicMaxProcesses(50)).toBe(20); // 50/10 = 5, floored to 20
expect(computeDynamicMaxProcesses(199)).toBe(20); // 199/10 ≈ 20
});
it('scales linearly within the old 0–3000 range', () => {
expect(computeDynamicMaxProcesses(500)).toBe(50);
expect(computeDynamicMaxProcesses(1000)).toBe(100);
expect(computeDynamicMaxProcesses(2999)).toBe(300);
});
it('grows past 300 for large repos — the regression that #2198 fixes', () => {
// 3001 symbols → 300 (just at the boundary)
expect(computeDynamicMaxProcesses(3001)).toBe(300);
// 3100 symbols → 310 — would have been capped to 300 before the fix
expect(computeDynamicMaxProcesses(3100)).toBe(310);
// 5000 symbols → 500
expect(computeDynamicMaxProcesses(5000)).toBe(500);
// 28000 symbols (real-world large repo) → 2800
expect(computeDynamicMaxProcesses(28000)).toBe(2800);
});
it('does NOT cap at 300 — fails if Math.min(300, ...) is reintroduced', () => {
const largeRepo = computeDynamicMaxProcesses(10000);
expect(largeRepo).toBe(1000);
expect(largeRepo).toBeGreaterThan(300);
});
});
});
/**
* D1/D2 — the trace walk must reach DEEP flows, not just shallow ones.
*
* The walk stops after a fixed NUMBER of traces, so traversal order decides
* which traces those are. Breadth-first reached every shallow terminal before
* any deep one, so the quota filled with the shortest paths in the graph and
* the walk stopped — `maxTraceDepth` was never approached.
*
* Measured on a 75k-node repo before the fix: of 300 processes NONE exceeded 7
* steps and 90% were 3-4, so a multi-hop business flow had no process that
* could represent it and `query` could only rank the mechanical pairs that did
* exist. What looked like a ranking problem was a construction problem.
*
* This fixture is that shape in miniature: one deep chain competing with enough
* shallow branches to exhaust the trace budget before the chain is reached.
*/
describe('process depth (D1/D2)', () => {
// Drives the walk DIRECTLY. Through `processProcesses` this is unobservable:
// `findEntryPoints` returns several starting points, so the deep chain is
// traced from inside it whatever the traversal order does — a test there
// passes under BOTH traversals and guards nothing.
const cfg = {
maxTraceDepth: 10,
maxBranching: 4,
maxProcesses: 75,
minSteps: 3,
maxEntryPointCandidates: 200,
};
const deepAndShallow = (order: readonly string[]): Map<string, string[]> => {
// Fan-out is capped at maxBranching (4), so the budget is exhausted BELOW
// the entry: three shallow branches carrying four immediate terminals each
// = 12 traces, exactly the walk budget (maxBranching * 3). Breadth-first
// records all twelve and stops before descending the deep branch at all.
const calls = new Map<string, string[]>();
calls.set('entry', [...order]);
for (const b of ['s1', 's2', 's3']) {
calls.set(b, [`${b}_l1`, `${b}_l2`, `${b}_l3`, `${b}_l4`]);
}
for (let i = 1; i <= 7; i++) calls.set(`d${i}`, [`d${i + 1}`]);
return calls;
};
it('descends a deep chain instead of spending the budget on shallow branches', () => {
const traces = traceFromEntryPoint('entry', deepAndShallow(['d1', 's1', 's2', 's3']), cfg);
const deepest = Math.max(0, ...traces.map((t) => t.length));
// Shallow terminals are 3 nodes. Anything longer proves it descended.
expect(deepest).toBeGreaterThan(3);
});
// Sibling ORDER, which the walk previously got backwards: `slice` selected
// the first N callees while `pop()` explored them last-first, so the budget
// went to the LAST-declared branch. For `main() { init(); …; shutdown(); }`
// that spends the walk on `shutdown` and can drop `init` — the earliest steps
// of a flow, which is the opposite of what a process describes.
//
// The consequence is honest and worth pinning: with a fixed trace budget, a
// deep branch declared AFTER enough shallow ones is not reached. That is a
// budget limitation, not a traversal one, and it must not be silent.
it('follows source order, so an early deep branch wins and a late one may not', () => {
const early = traceFromEntryPoint('entry', deepAndShallow(['d1', 's1', 's2', 's3']), cfg);
const late = traceFromEntryPoint('entry', deepAndShallow(['s1', 's2', 's3', 'd1']), cfg);
expect(Math.max(0, ...early.map((t) => t.length))).toBeGreaterThan(3);
// Not asserted as a desirable outcome — asserted so a change to the budget
// shows up here rather than silently altering which flows exist.
expect(Math.max(0, ...late.map((t) => t.length))).toBe(3);
});
// The `processProcesses`-level depth test that used to sit here was VACUOUS,
// and the note at the top of this describe says exactly why: `findEntryPoints`
// returns several starting points, so the deep chain gets traced from inside
// it whatever the traversal does. Measured: under breadth-first the same
// fixture still yielded a deepest stepCount of 8, so the assertion passed
// with the production change reverted and guarded nothing.
//
// What IS observable at this level is which traces survive SELECTION, and
// that is asserted in the diversity describe below. Traversal order is
// asserted against `traceFromEntryPoint` directly, above.
});
describe('sink-terminated flows (R3-6)', () => {
const addFn = (
graph: ReturnType<typeof createKnowledgeGraph>,
id: string,
line: number,
): void => {
graph.addNode({
id,
label: 'Function',
properties: {
name: id.split(':')[1],
filePath: 'src/flow.ts',
startLine: line,
endLine: line + 2,
},
});
};
const addCall = (
graph: ReturnType<typeof createKnowledgeGraph>,
from: string,
to: string,
): void => {
graph.addRelationship({
id: `rel:${from}->${to}`,
sourceId: from,
targetId: to,
type: 'CALLS',
confidence: 1,
reason: 'test',
});
};
/**
* The shape the whole item is about: a business flow whose meaningful
* endpoint CALLS ONWARD into helpers. `placeOrder` is where the program does
* something; `formatDate` is merely where control stops.
*/
const flowGraph = (): ReturnType<typeof createKnowledgeGraph> => {
const graph = createKnowledgeGraph();
addFn(graph, 'func:scan', 1);
addFn(graph, 'func:score', 10);
addFn(graph, 'func:placeOrder', 20);
addFn(graph, 'func:formatDate', 30);
addFn(graph, 'func:pad', 40);
addCall(graph, 'func:scan', 'func:score');
addCall(graph, 'func:score', 'func:placeOrder');
addCall(graph, 'func:placeOrder', 'func:formatDate');
addCall(graph, 'func:formatDate', 'func:pad');
return graph;
};
// `placeOrder` spans lines 20-22, so an outward action on line 21 belongs to
// it — the attribution the file-level FETCHES edge could not express.
const ORDER_SITE = [{ filePath: 'src/flow.ts', lineNumber: 21 }];
it('ends a trace where the program reaches outward', async () => {
const result = await processProcesses(flowGraph(), [], undefined, {}, ORDER_SITE);
expect(result.processes.map((p) => p.terminalId)).toContain('func:placeOrder');
});
// The half a naive implementation gets wrong: emitting the sink trace at the
// walk and then letting subset-removal delete it one step later is a no-op,
// because a sink-terminated flow is BY DEFINITION a prefix of the longer
// chain that runs on past it.
it('keeps the sink flow even though it is a prefix of a longer chain', async () => {
const result = await processProcesses(flowGraph(), [], undefined, {}, ORDER_SITE);
const terminals = result.processes.map((p) => p.terminalId);
expect(terminals).toContain('func:placeOrder');
// The longer chain still exists — the two answer different questions.
expect(terminals).toContain('func:pad');
});
it('ranks the sink flow above the leaf chain', async () => {
const result = await processProcesses(flowGraph(), [], undefined, {}, ORDER_SITE);
const first = result.processes[0]?.terminalId;
expect(first).toBe('func:placeOrder');
});
// Without sites, behaviour must be exactly what it was.
it('changes nothing when no outward action is known', async () => {
const result = await processProcesses(flowGraph(), [], undefined, {}, []);
expect(result.processes.map((p) => p.terminalId)).not.toContain('func:placeOrder');
});
it('attributes a site to the INNERMOST enclosing function', () => {
const graph = createKnowledgeGraph();
// An outer function spanning the inner one; the inner performs the call.
graph.addNode({
id: 'func:outer',
label: 'Function',
properties: { name: 'outer', filePath: 'src/a.ts', startLine: 1, endLine: 50 },
});
graph.addNode({
id: 'func:inner',
label: 'Function',
properties: { name: 'inner', filePath: 'src/a.ts', startLine: 10, endLine: 20 },
});
const sinks = buildSinkFunctionSet(graph, [{ filePath: 'src/a.ts', lineNumber: 15 }]);
expect(sinks.has('func:inner')).toBe(true);
expect(sinks.has('func:outer')).toBe(false);
});
});
describe('process selection diversity (R2-3)', () => {
const addFn = (graph: ReturnType<typeof createKnowledgeGraph>, id: string): void => {
graph.addNode({
id,
label: 'Function',
properties: { name: id.split(':')[1], filePath: 'src/a.ts', startLine: 1, endLine: 2 },
});
};
const addCall = (
graph: ReturnType<typeof createKnowledgeGraph>,
from: string,
to: string,
): void => {
graph.addRelationship({
id: `rel:${from}->${to}`,
sourceId: from,
targetId: to,
type: 'CALLS',
confidence: 1,
reason: 'test',
});
};
// The shape that crowded the reporting repo's list: many entry points whose
// deepest chains all bottom out in the SAME utility, plus a shorter flow
// ending somewhere of its own. Ranking on depth alone hands every slot to
// the first group and the reader learns one thing many times.
it('does not let one terminal take every slot', async () => {
const graph = createKnowledgeGraph();
// Six entry points, each with a 5-node chain into one shared utility.
addFn(graph, 'func:sharedUtil');
for (let e = 1; e <= 6; e++) {
let prev = `func:entry${e}`;
addFn(graph, prev);
for (let i = 1; i <= 3; i++) {
const mid = `func:e${e}_m${i}`;
addFn(graph, mid);
addCall(graph, prev, mid);
prev = mid;
}
addCall(graph, prev, 'func:sharedUtil');
}
// One shorter, distinct flow — the "business flow" analogue.
addFn(graph, 'func:ownEntry');
addFn(graph, 'func:ownMid');
addFn(graph, 'func:ownTerminal');
addCall(graph, 'func:ownEntry', 'func:ownMid');
addCall(graph, 'func:ownMid', 'func:ownTerminal');
const result = await processProcesses(graph, [], undefined, { maxProcesses: 4 });
const terminals = result.processes.map((p) => p.terminalId);
const sharedCount = terminals.filter((t) => t === 'func:sharedUtil').length;
// Under depth-only ranking every one of the four slots goes to a
// five-node chain ending in sharedUtil.
expect(sharedCount).toBeLessThan(terminals.length);
expect(new Set(terminals).size).toBeGreaterThan(1);
});
it('keeps a shorter flow with its own terminal rather than a fifth duplicate', async () => {
const graph = createKnowledgeGraph();
addFn(graph, 'func:sharedUtil');
for (let e = 1; e <= 6; e++) {
let prev = `func:entry${e}`;
addFn(graph, prev);
for (let i = 1; i <= 3; i++) {
const mid = `func:e${e}_m${i}`;
addFn(graph, mid);
addCall(graph, prev, mid);
prev = mid;
}
addCall(graph, prev, 'func:sharedUtil');
}
addFn(graph, 'func:ownEntry');
addFn(graph, 'func:ownMid');
addFn(graph, 'func:ownTerminal');
addCall(graph, 'func:ownEntry', 'func:ownMid');
addCall(graph, 'func:ownMid', 'func:ownTerminal');
const result = await processProcesses(graph, [], undefined, { maxProcesses: 4 });
expect(result.processes.map((p) => p.terminalId)).toContain('func:ownTerminal');
});
});
// ============================================================================
// DETERMINISM (W2-5)
// ============================================================================
//
// The persisted graph must not depend on the order nodes and edges happened to
// be inserted. Four sorts in this file ranked by score or length alone and
// returned 0 on a tie; `Array.prototype.sort` is stable, so a 0 preserves INPUT
// order, which traces back to `graph.iterNodes()` — i.e. to the order the
// filesystem enumerated files. Under `maxProcesses` capping that decided which
// `Process` and `STEP_IN_PROCESS` nodes were persisted at all.
//
// Reproduced before the fix: two equal three-step flows with `maxProcesses: 1`
// selected `handleAlpha`; inserting the identical nodes and CALLS edges in
// reverse selected `handleBeta`. Same repository, same commit, different graph.
//
// This asserts the INVARIANT rather than any one sort, so it covers all four
// sites — and any future one — without needing to know where they are.
describe('process detection is insertion-order invariant (W2-5)', () => {
const buildGraph = (reverse: boolean) => {
const graph = createKnowledgeGraph();
const memberships: CommunityMembership[] = [];
const chains = [
['handleAlpha', 'midAlpha', 'endAlpha'],
['handleBeta', 'midBeta', 'endBeta'],
['handleGamma', 'midGamma', 'endGamma'],
];
const ordered = reverse ? [...chains].reverse() : chains;
for (const chain of ordered) {
for (const name of chain) {
graph.addNode({
id: `func:${name}`,
label: 'Function',
properties: {
name,
filePath: `src/${name}.ts`,
startLine: 1,
endLine: 10,
isExported: true,
},
});
memberships.push({ nodeId: `func:${name}`, communityId: 'community:0' });
}
}
for (const chain of ordered) {
for (let i = 0; i < chain.length - 1; i++) {
graph.addRelationship({
id: `call:${chain[i]}`,
sourceId: `func:${chain[i]}`,
targetId: `func:${chain[i + 1]}`,
type: 'CALLS',
confidence: 0.9,
reason: 'import-resolved',
});
}
}
return { graph, memberships };
};
it('selects the same process under a cap regardless of insertion order', async () => {
// The capped case is the one that mattered: with room for everything the
// set is equal either way and only the ORDER differs, so a cap is what turns
// an ordering difference into a persistence difference.
const forward = buildGraph(false);
const reversed = buildGraph(true);
const a = await processProcesses(forward.graph, forward.memberships, undefined, {
maxProcesses: 1,
});
const b = await processProcesses(reversed.graph, reversed.memberships, undefined, {
maxProcesses: 1,
});
expect(a.processes.length).toBe(1);
expect(a.processes[0]?.entryPointId).toBe(b.processes[0]?.entryPointId);
});
it('produces an identical process set uncapped', async () => {
const forward = buildGraph(false);
const reversed = buildGraph(true);
const a = await processProcesses(forward.graph, forward.memberships);
const b = await processProcesses(reversed.graph, reversed.memberships);
const shape = (r: Awaited<ReturnType<typeof processProcesses>>): string[] =>
r.processes.map((p) => `${p.entryPointId}->${p.terminalId}`).sort();
expect(shape(a).length).toBeGreaterThan(0);
expect(shape(a)).toEqual(shape(b));
});
// The TRACE-RANK tie specifically. The chains above differ by entry point, so
// they are separated by the entry-point sort before trace ranking is reached —
// which means they do NOT exercise `rankedByInterest`'s tiebreak, verified by
// mutation. This fixture gives ONE entry point two equal-length branches to
// different terminals, so the only thing that can order them is the trace
// comparator itself.
const buildBranchedGraph = (reverse: boolean) => {
const graph = createKnowledgeGraph();
const memberships: CommunityMembership[] = [];
const branches = [
['midAlpha', 'endAlpha'],
['midBeta', 'endBeta'],
];
const ordered = reverse ? [...branches].reverse() : branches;
const add = (name: string, isExported: boolean) => {
graph.addNode({
id: `func:${name}`,
label: 'Function',
properties: { name, filePath: `src/${name}.ts`, startLine: 1, endLine: 10, isExported },
});
memberships.push({ nodeId: `func:${name}`, communityId: 'community:0' });
};
add('handleShared', true);
for (const branch of ordered) for (const name of branch) add(name, true);
for (const branch of ordered) {
graph.addRelationship({
id: `call:root:${branch[0]}`,
sourceId: 'func:handleShared',
targetId: `func:${branch[0]}`,
type: 'CALLS',
confidence: 0.9,
reason: 'import-resolved',
});
graph.addRelationship({
id: `call:${branch[0]}`,
sourceId: `func:${branch[0]}`,
targetId: `func:${branch[1]}`,
type: 'CALLS',
confidence: 0.9,
reason: 'import-resolved',
});
}
return { graph, memberships };
};
it('orders two equal-length traces from ONE entry point deterministically', async () => {
const forward = buildBranchedGraph(false);
const reversed = buildBranchedGraph(true);
const a = await processProcesses(forward.graph, forward.memberships, undefined, {
maxProcesses: 1,
});
const b = await processProcesses(reversed.graph, reversed.memberships, undefined, {
maxProcesses: 1,
});
expect(a.processes.length).toBe(1);
expect(a.processes[0]?.terminalId).toBe(b.processes[0]?.terminalId);
});
it('emits the traces in the same ORDER, not merely the same set', async () => {
// Order is what the cap consumes, so a set-only assertion would pass while
// the defect persisted.
const forward = buildGraph(false);
const reversed = buildGraph(true);
const a = await processProcesses(forward.graph, forward.memberships);
const b = await processProcesses(reversed.graph, reversed.memberships);
expect(a.processes.map((p) => p.entryPointId)).toEqual(b.processes.map((p) => p.entryPointId));
});
});
// W2-3. Every ceiling in this file used to fire silently: the result came back
// looking whole and no consumer could tell it was partial. The code's own
// comment said as much ("a silently truncating cap reads as 'this is
// everything'") and then only logged at debug — a log nobody has enabled is not
// a disclosure. Each counter below is asserted against a graph built to trip
// exactly one ceiling.
describe('truncation is reported, not swallowed (W2-3)', () => {
const addFn = (graph: ReturnType<typeof createKnowledgeGraph>, id: string): void => {
graph.addNode({
id,
label: 'Function',
properties: { name: id.split(':')[1], filePath: 'src/a.ts', startLine: 1, endLine: 2 },
});
};
const addCall = (
graph: ReturnType<typeof createKnowledgeGraph>,
from: string,
to: string,
): void => {
graph.addRelationship({
id: `rel:${from}->${to}`,
sourceId: from,
targetId: to,
type: 'CALLS',
confidence: 1,
reason: 'test',
});
};
/** A chain of `len` functions, prefixed so several can coexist in one graph. */
const addChain = (
graph: ReturnType<typeof createKnowledgeGraph>,
prefix: string,
len: number,
): void => {
for (let i = 0; i < len; i++) addFn(graph, `func:${prefix}${i}`);
for (let i = 0; i < len - 1; i++)
addCall(graph, `func:${prefix}${i}`, `func:${prefix}${i + 1}`);
};
it('reports nothing truncated when every flow fits', async () => {
// Asserted FIRST: every positive assertion below is meaningless if the flag
// is simply always true.
const graph = createKnowledgeGraph();
addChain(graph, 'a', 3);
const result = await processProcesses(graph, [], undefined, {
maxTraceDepth: 10,
maxBranching: 4,
maxProcesses: 50,
});
expect(result.processes.length).toBeGreaterThan(0);
expect(result.stats.truncation.truncated).toBe(false);
expect(result.stats.truncation).toMatchObject({
entryPointsUnexplored: 0,
walksCutByBudget: 0,
tracesDepthCapped: 0,
calleesDropped: 0,
processesDropped: 0,
});
});
it('counts entry points that were never traced at all', async () => {
// The trace loop stops on the TRACE quota (maxProcesses * 2), so the
// remaining entry points are not "no flows found" — nothing looked at them.
const graph = createKnowledgeGraph();
for (let e = 0; e < 8; e++) addChain(graph, `e${e}_`, 3);
const result = await processProcesses(graph, [], undefined, { maxProcesses: 1 });
const { entryPointsFound } = result.stats;
const { entryPointsUnexplored } = result.stats.truncation;
expect(entryPointsFound).toBeGreaterThan(0);
// Strictly between: some WERE traced, so this is a real early exit rather
// than "the loop never ran", and strictly less than the total, so the
// counter is not just echoing `entryPointsFound` back.
expect(entryPointsUnexplored).toBeGreaterThan(0);
expect(entryPointsUnexplored).toBeLessThan(entryPointsFound);
expect(result.stats.truncation.truncated).toBe(true);
});
it('counts traces that stop at maxTraceDepth rather than at a terminal', async () => {
// The trace is KEPT, but it is a prefix of a longer flow, and only this
// counter tells the two apart downstream.
const graph = createKnowledgeGraph();
addChain(graph, 'deep', 12);
const result = await processProcesses(graph, [], undefined, { maxTraceDepth: 4 });
expect(result.stats.truncation.tracesDepthCapped).toBeGreaterThan(0);
expect(result.stats.truncation.truncated).toBe(true);
});
it('counts callees never followed because of maxBranching', async () => {
const graph = createKnowledgeGraph();
addFn(graph, 'func:fanout');
for (let c = 0; c < 9; c++) {
addChain(graph, `leaf${c}_`, 2);
addCall(graph, 'func:fanout', `func:leaf${c}_0`);
}
const result = await processProcesses(graph, [], undefined, { maxBranching: 2 });
expect(result.stats.truncation.calleesDropped).toBeGreaterThan(0);
expect(result.stats.truncation.truncated).toBe(true);
});
it('counts entry-point walks abandoned with branches still on the stack', async () => {
// Per-entry-point trace budget is `maxBranching * 3`, so a tree that is
// wide enough exhausts it with unexplored branches left. Every node here
// has EXACTLY `maxBranching` callees, which keeps `calleesDropped` at zero
// so this asserts its own counter and not a neighbour's.
const graph = createKnowledgeGraph();
addFn(graph, 'func:root');
for (let a = 0; a < 4; a++) {
addFn(graph, `func:mid${a}`);
addCall(graph, 'func:root', `func:mid${a}`);
for (let b = 0; b < 4; b++) {
addFn(graph, `func:leaf${a}_${b}`);
addCall(graph, `func:mid${a}`, `func:leaf${a}_${b}`);
}
}
const result = await processProcesses(graph, [], undefined, { maxBranching: 4 });
expect(result.stats.truncation.walksCutByBudget).toBeGreaterThan(0);
expect(result.stats.truncation.calleesDropped).toBe(0);
expect(result.stats.truncation.truncated).toBe(true);
});
it('counts deduplicated traces dropped by the maxProcesses cap', async () => {
// Counted against the DEDUPED population: the gap between raw traces and
// deduped ones is deduplication working, which is not truncation.
const graph = createKnowledgeGraph();
for (let e = 0; e < 6; e++) addChain(graph, `p${e}_`, 3);
const result = await processProcesses(graph, [], undefined, { maxProcesses: 2 });
expect(result.processes.length).toBeLessThanOrEqual(2);
expect(result.stats.truncation.processesDropped).toBeGreaterThan(0);
expect(result.stats.truncation.truncated).toBe(true);
});
it('leaves the four pre-existing stats untouched', async () => {
// The field is ADDITIVE. A consumer reading totalProcesses must not have to
// learn about truncation to keep working.
const graph = createKnowledgeGraph();
addChain(graph, 'x', 3);
const result = await processProcesses(graph, []);
expect(result.stats).toMatchObject({
totalProcesses: expect.any(Number),
crossCommunityCount: expect.any(Number),
avgStepCount: expect.any(Number),
entryPointsFound: expect.any(Number),
});
});
});
// The ceiling the first pass of W2-3 MISSED. `findEntryPoints` ranks every
// scoring candidate and then keeps the top 200, so `entryPointsUnexplored` —
// computed over the list it RETURNS — can only ever see the survivors, and the
// cap that decides how much of a repository is looked at at all reported
// nothing. On anything above 200 candidates it is the DOMINANT ceiling.
describe('the entry-point candidate cap is disclosed too', () => {
const addFn = (graph: ReturnType<typeof createKnowledgeGraph>, id: string): void => {
graph.addNode({
id,
label: 'Function',
properties: { name: id.split(':')[1], filePath: 'src/a.ts', startLine: 1, endLine: 2 },
});
};
const addCall = (
graph: ReturnType<typeof createKnowledgeGraph>,
from: string,
to: string,
): void => {
graph.addRelationship({
id: `rel:${from}->${to}`,
sourceId: from,
targetId: to,
type: 'CALLS',
confidence: 1,
reason: 'test',
});
};
/**
* 205 three-node chains. Every node with at least one callee scores above
* zero, so this is 410 candidates for 200 slots — and NOTHING else is
* truncated: the chains are three long (under `maxTraceDepth`), single-callee
* (under `maxBranching`), one trace each (under the per-entry budget), and
* `maxProcesses` is set high enough that none are dropped.
*/
const manyCandidates = (): ReturnType<typeof createKnowledgeGraph> => {
const graph = createKnowledgeGraph();
for (let c = 0; c < 205; c++) {
for (let i = 0; i < 3; i++) addFn(graph, `func:c${c}_${i}`);
for (let i = 0; i < 2; i++) addCall(graph, `func:c${c}_${i}`, `func:c${c}_${i + 1}`);
}
return graph;
};
it('counts the candidates that never made the ranked list', async () => {
const result = await processProcesses(manyCandidates(), [], undefined, {
maxProcesses: 1000,
});
// 410 candidates, 200 kept: the counter reports what `entryPointsFound`
// structurally cannot.
expect(result.stats.entryPointsFound).toBe(200);
expect(result.stats.truncation.entryPointCandidatesDropped).toBe(210);
});
it('folds the new ceiling into `truncated`, and fires ALONE', async () => {
// Asserted exhaustively rather than as `truncated === true`: if any other
// counter were also non-zero the first assertion would prove nothing about
// which ceiling was detected.
const result = await processProcesses(manyCandidates(), [], undefined, {
maxProcesses: 1000,
});
expect(result.stats.truncation).toEqual({
truncated: true,
entryPointCandidatesDropped: 210,
entryPointsUnexplored: 0,
walksCutByBudget: 0,
tracesDepthCapped: 0,
calleesDropped: 0,
processesDropped: 0,
});
});
it('reports nothing dropped when every candidate fits', async () => {
// The control for the two above — the counter must not simply always fire.
const graph = createKnowledgeGraph();
for (let c = 0; c < 5; c++) {
for (let i = 0; i < 3; i++) addFn(graph, `func:s${c}_${i}`);
for (let i = 0; i < 2; i++) addCall(graph, `func:s${c}_${i}`, `func:s${c}_${i + 1}`);
}
const result = await processProcesses(graph, [], undefined, { maxProcesses: 1000 });
expect(result.stats.truncation.entryPointCandidatesDropped).toBe(0);
expect(result.stats.truncation.truncated).toBe(false);
});
it('honors maxEntryPointCandidates instead of the compiled 200 (#3313)', async () => {
const result = await processProcesses(manyCandidates(), [], undefined, {
maxProcesses: 1000,
maxEntryPointCandidates: 410,
});
expect(result.stats.entryPointsFound).toBe(410);
expect(result.stats.truncation.entryPointCandidatesDropped).toBe(0);
});
it('drops one candidate when the override is one below the list length (#3313)', async () => {
const result = await processProcesses(manyCandidates(), [], undefined, {
maxProcesses: 1000,
maxEntryPointCandidates: 409,
});
expect(result.stats.entryPointsFound).toBe(409);
expect(result.stats.truncation.entryPointCandidatesDropped).toBe(1);
});
});
// The three trace sorts in this file each joined the path inside the COMPARATOR
// — up to four joins per comparison — and two of the three joined on a SPACE.
// Both are now one shared helper keyed on NUL.
//
// The separator is not cosmetic. Node ids embed file paths and a path may
// contain a space, so `['A B', 'C']` and `['A', 'B C']` produce the same
// space-joined key, the comparator returns 0, and a stable sort falls back to
// the input order the tiebreak exists to remove — the exact defect W2-5 fixed,
// reintroduced by the key. `traceKey` two functions away already pads with `->`
// because an unanchored join is ambiguous (#2894); this is the same lesson.
describe('trace ordering is total and allocation-free (#2899 follow-up)', () => {
const noSink = (): boolean => false;
/**
* A deterministic 200-trace corpus with NO space in any id — i.e. the corpus
* on which the old space-joined key and the new NUL-joined key must agree.
*
* Lehmer LCG rather than `Math.random`: the assertion below is an ORDER
* IDENTITY claim, and evidence for it has to be reproducible. Every trace ends
* in an id unique to it, which is what keeps subsumption out of the way so the
* function returns exactly its sorted input.
*/
const seededCorpus = (): string[][] => {
let seed = 20260809;
const next = (): number => (seed = (seed * 48271) % 2147483647);
const traces: string[][] = [];
for (let i = 0; i < 200; i++) {
const depth = 3 + (next() % 3);
const trace: string[] = [];
for (let j = 0; j < depth - 1; j++) trace.push(`n${next() % 6}`);
trace.push(`term${i}`);
traces.push(trace);
}
return traces;
};
it('produces exactly the order the space-joined comparator produced', () => {
// ORDER IDENTITY. The refactor is only allowed to change WHEN keys are
// built, never the resulting order, because the order is what the
// `maxProcesses` cap consumes. Both separators sort below every character a
// node id can contain, so joining on either is order-equivalent to comparing
// the arrays element by element — this pins that equivalence instead of
// asserting it in a comment.
const corpus = seededCorpus();
const legacy = [...corpus].sort(
(a, b) =>
b.length - a.length || (a.join(' ') < b.join(' ') ? -1 : a.join(' ') > b.join(' ') ? 1 : 0),
);
expect(deduplicateTraces(corpus, noSink)).toEqual(legacy);
});
it('orders a pair that COLLIDES under a space separator', () => {
// `['r', 'a b', 'c']` and `['r', 'a', 'b c']` both join to "r a b c", so the
// space comparator returns 0 and `Array.prototype.sort`, being stable, hands
// the decision back to input order. Under NUL they differ at the third
// character and the order is fixed.
const first: string[][] = [
['r', 'a b', 'c'],
['r', 'a', 'b c'],
];
const second: string[][] = [
['r', 'a', 'b c'],
['r', 'a b', 'c'],
];
expect(deduplicateTraces(first, noSink)).toEqual(deduplicateTraces(second, noSink));
});
});
// The same collision, reached through the WHOLE processor rather than one
// helper — because a space in a node id is not hypothetical (ids embed file
// paths, and directories with spaces are ordinary), and because W2-5 states its
// guarantee over `processProcesses`, not over its internals.
//
// The observable defect was narrower than the collision itself: `rankedByInterest`
// already keyed on NUL, so the FINAL rank was safe. It was `deduplicateByEndpoints`
// — which keeps ONE representative per entry->terminal pair — that still joined on
// a space, so when two equal-length paths between the SAME two endpoints collided,
// which one survived was decided by insertion order. The surviving path is what
// the `Process` node records, so the persisted graph differed.
describe('insertion-order invariance survives ids containing spaces', () => {
/**
* Two four-step paths from `func:r` to `func:z`, via `func:a b -> func:c` and
* via `func:a -> b func:c`. Both join to "func:r func:a b func:c func:z" under
* a space, so the endpoint-dedup comparator returned 0 and kept whichever the
* DFS happened to reach first. Under NUL they differ at the separator after
* `func:a` and the representative is fixed.
*/
const collidingGraph = (reverse: boolean): ReturnType<typeof createKnowledgeGraph> => {
const graph = createKnowledgeGraph();
const add = (id: string, name: string): void => {
graph.addNode({
id,
label: 'Function',
properties: { name, filePath: 'src/a.ts', startLine: 1, endLine: 2 },
});
};
const call = (from: string, to: string): void => {
graph.addRelationship({
id: `rel:${from}=>${to}`,
sourceId: from,
targetId: to,
type: 'CALLS',
confidence: 1,
reason: 'test',
});
};
const branches: [string, string][] = [
['func:a b', 'func:c'],
['func:a', 'b func:c'],
];
const ordered = reverse ? [...branches].reverse() : branches;
add('func:r', 'r');
add('func:z', 'z');
for (const [mid, next] of ordered) {
add(mid, 'mid');
add(next, 'next');
}
for (const [mid, next] of ordered) {
call('func:r', mid);
call(mid, next);
call(next, 'func:z');
}
return graph;
};
it('keeps the same representative path whichever branch is inserted first', async () => {
const a = await processProcesses(collidingGraph(false), []);
const b = await processProcesses(collidingGraph(true), []);
// One entry->terminal pair, so endpoint dedup keeps exactly one path — and
// that path is what the Process node records.
expect(a.processes.length).toBe(1);
expect(a.processes[0]?.trace).toEqual(b.processes[0]?.trace);
});
it('selects the same flow under a cap whichever branch is inserted first', async () => {
const a = await processProcesses(collidingGraph(false), [], undefined, { maxProcesses: 1 });
const b = await processProcesses(collidingGraph(true), [], undefined, { maxProcesses: 1 });
expect(a.processes.length).toBe(1);
expect(a.processes[0]?.trace).toEqual(b.processes[0]?.trace);
});
});
// #2894. `deduplicateTraces` decided subsumption with an UNANCHORED
// `String.includes`, so a match could begin in the middle of a node id and a
// trace was discarded against a chain it does not appear in.
//
// Reported as measured-inert — the collision needs one node id to be a strict
// suffix of another at a `->` boundary, and real ids (`Function:<path>:<name>`)
// do not produce that. These use bare ids to exercise the predicate directly,
// which is the only way to reach it: the shape cannot be built from realistic
// ids, and that is precisely why nothing caught it.
describe('trace subsumption matches whole steps only (#2894)', () => {
const noSink = (): boolean => false;
it('keeps a trace whose key appears mid-identifier in a longer trace', () => {
// 'X->AA->B'.includes('A->B') is true, but `A` is not a step of that chain.
const kept = deduplicateTraces(
[
['X', 'AA', 'B'],
['A', 'B'],
],
noSink,
);
expect(kept.map((t) => t.join('->'))).toContain('A->B');
});
it('still discards a GENUINE sub-path', () => {
// The behaviour the predicate exists for, pinned so the fix cannot be
// "stop subsuming anything", which would pass the test above trivially.
const kept = deduplicateTraces(
[
['A', 'B', 'C'],
['A', 'B'],
],
noSink,
);
expect(kept.map((t) => t.join('->'))).toEqual(['A->B->C']);
});
it('discards a sub-path that is a SUFFIX of a longer trace', () => {
// Padding both ends must not break suffix or prefix subsumption.
const kept = deduplicateTraces(
[
['A', 'B', 'C'],
['B', 'C'],
],
noSink,
);
expect(kept.map((t) => t.join('->'))).toEqual(['A->B->C']);
});
});