GitNexus/gitnexus/test/unit/analyze-worker-ipc.test.ts
Gergo Magyar 6ad8d7d389 feat(storage): share the index store between clones automatically (#3352)
Clones of one repository now share like linked worktrees. A clone whose
normalized origin URL matches another registered, still-present clone
joins that clone's store, or founds one (keyed on its own path) that the
sibling joins on its next analyze. A lone clone keeps its own .gitnexus.
Graphs stay keyed by commit and feature key, so clones only ever share a
graph built from the same commit with the same settings.

`analyze --no-share` now records a lasting opt-out (`shareOptOut` on the
registry entry, preserved across re-registration); `--share-with` clears
it. The analyze worker reports the storage it wrote over IPC so the
server settles a clone's first shared slot.

A query-time base-plus-overlay graph stays out: LadybugDB reads one
database per query. Instead, private graph copies record whether the
filesystem cloned them copy-on-write (sharing unchanged pages on disk)
or made a full copy, and `status` reports it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:43:48 +00:00

110 lines
4.6 KiB
TypeScript

/**
* #2112 boundary audit — the analyze-worker → parent IPC projection.
*
* The forked analyze worker reports completion over default-JSON child_process
* IPC. `AnalyzeResult.pipelineResult` carries the live `KnowledgeGraph` (closure
* methods, getter-materialized arrays) and can transitively hold a BigInt or a
* circular reference — all of which break `JSON.stringify` (the IPC serializer):
* methods drop silently, BigInt/circular THROW. `projectAnalyzeResultForIpc`
* strips `pipelineResult` to an explicit JSON-safe allowlist so the boundary is
* safe by construction. These tests pin that contract.
*/
import { describe, it, expect } from 'vitest';
import { projectAnalyzeResultForIpc } from '../../src/server/analyze-worker-ipc.js';
import type { AnalyzeResult } from '../../src/core/run-analyze.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
/**
* An `AnalyzeResult` whose `pipelineResult` is hostile to JSON in all three
* ways the real `KnowledgeGraph` can be: a method (dropped), a circular ref
* (throws), and a BigInt (throws).
*/
function hostileResult(): AnalyzeResult {
const graph: Record<string, unknown> = {
nodes: [{ id: 'a', label: 'Function', properties: { name: 'a' } }],
relationships: [],
forEachNode: () => {}, // own function property — JSON drops it silently
bigCount: 10n, // BigInt — JSON.stringify throws
};
graph.self = graph; // circular — JSON.stringify throws
return {
repoName: 'demo',
repoPath: '/repos/demo',
storagePath: '/repos/demo/.gitnexus',
stats: { files: 3, nodes: 1, edges: 0 },
alreadyUpToDate: false,
ftsSkipped: true,
pipelineResult: { graph, repoPath: '/repos/demo', totalFileCount: 3 },
};
}
describe('#2112: analyze-worker IPC projection', () => {
it('drops pipelineResult and preserves the scalar fields the parent consumes', () => {
const projected = projectAnalyzeResultForIpc(hostileResult());
expect(projected).toEqual({
repoName: 'demo',
repoPath: '/repos/demo',
stats: { files: 3, nodes: 1, edges: 0 },
alreadyUpToDate: false,
ftsRepairedOnly: undefined,
ftsSkipped: true,
storagePath: '/repos/demo/.gitnexus',
});
expect('pipelineResult' in projected).toBe(false);
});
it('the projection is JSON-serializable (survives the default child_process IPC channel)', () => {
const projected = projectAnalyzeResultForIpc(hostileResult());
// The real failure mode: process.send runs JSON.stringify under the hood.
expect(() => JSON.stringify(projected)).not.toThrow();
const roundTripped = JSON.parse(JSON.stringify(projected));
expect(roundTripped.repoName).toBe('demo');
expect(roundTripped.stats.nodes).toBe(1);
});
it('anchors the hazard: serializing the RAW result throws (the bug the projection prevents)', () => {
// Without the projection, `send({type:'complete', result})` would throw a
// TypeError in the worker, get caught, and mis-report this success as a
// failure. This assertion documents why the projection exists.
expect(() => JSON.stringify(hostileResult())).toThrow(TypeError);
});
it('drops a REAL KnowledgeGraph from pipelineResult — the payload stays tiny (graph not materialized)', () => {
// The synthetic cases above use a hand-built hostile object; this uses the
// real createKnowledgeGraph the server path actually puts in pipelineResult,
// whose nodes/relationships getters would materialize the whole graph into
// arrays under JSON.stringify. The projection must drop it entirely.
const graph = createKnowledgeGraph();
for (let i = 0; i < 50; i++) {
graph.addNode({
id: `n${i}`,
label: 'Function',
properties: { name: `n${i}`, filePath: 'x.ts' },
});
}
graph.addRelationship({ id: 'n0->n1', source: 'n0', target: 'n1', type: 'CALLS' });
const result: AnalyzeResult = {
repoName: 'demo',
repoPath: '/r',
storagePath: '/r/.gitnexus',
stats: { nodes: 50, edges: 1 },
pipelineResult: {
graph,
repoPath: '/r',
totalFileCount: 50,
resolutionOutcomes: [],
usedWorkerPool: true,
},
};
const projected = projectAnalyzeResultForIpc(result);
expect('pipelineResult' in projected).toBe(false);
const json = JSON.stringify(projected);
// A materialized 50-node graph would be thousands of bytes; the projection
// is just the scalar fields, so this stays small.
expect(json.length).toBeLessThan(300);
const rt = JSON.parse(json);
expect(rt.repoName).toBe('demo');
expect(rt.stats.nodes).toBe(50);
});
});