* test(#3674): characterize existing wave-dispatch output and add tests for the extracted partitioner Pins resolveWaveDispatch's and emitWorkflowScript's current, unextracted output (chain-overlap, disjoint-empty-set, and a multi-wave/multi-stage golden script) as a regression safety net ahead of extracting partitionStages into a standalone module. Also adds the new module's unit and property tests (test matrix rows 1-11) against its expected public API, which does not exist yet and is added in the next commit. * feat(#3674): extract file-overlap partitioner into a shared, generic module Moves partitionStages' greedy first-fit file-overlap algorithm into a new, dependency-free src/file-overlap-partitioner.cts module (partitionByFileOverlap), generalized over a plain {id, files}[] shape rather than claude-orchestration.cts's Plan/Wave interfaces. partitionStages becomes a thin adapter mapping its own Plan[] shape onto the generic input and back — behavior-preserving, no dependency ordering, no path normalization, no filesystem access moved or added. Enables a future consumer (quick-batch, #3675 / ADR-1239) to reuse the same primitive without pulling in orchestration internals. * docs(#3674): register the file-overlap-partitioner module bookkeeping New src/*.cts -> bin/lib/*.cjs modules need four hand-maintained registrations beyond the code itself: .gitignore (compiled artifact), eslint.config.mjs (ADR-457: lint the .cts, not the emitted .cjs), docs/INVENTORY.md's CLI Modules roster row (regenerated via gen-inventory-manifest.cjs --write), and a CONTEXT.md glossary entry matching the convention set by similarly-scoped leaf modules (text-lines.cts, plan-dependency-graph.cts, spec-section.cts). * fix(#3674): alphabetize INVENTORY.md row, manifest regen no-op, fast-check import already correct - docs/INVENTORY.md: move file-overlap-partitioner.cjs row to alphabetical position - docs/INVENTORY-MANIFEST.json: regenerated via gen-inventory-manifest.cjs --write, produced no diff (manifest is keyed by content, not row order) - tests/claude-orchestration.test.cjs's direct require('fast-check') is correct as-is: tests/helpers/fast-check-setup.cjs's own docstring scopes the shared-seed wrapper to "every *.property.test.cjs file"; claude-orchestration.test.cjs is not a .property.test.cjs file, and every .property.test.cjs file sampled uses the wrapper consistently. No outlier. * fix(#3674): constrain the no-overlap property test to unique ids, fixing an ambiguous duplicate-id reconstruction The `no two plans in the same stage share a modified file` property reconstructs which physical item produced each output id via `remaining.findIndex(r => r.id === id)`. Under duplicate ids (an explicitly-supported input shape for `partitionByFileOverlap`) that reconstruction can pick the wrong physical occurrence, producing a false-positive overlap failure (observed counterexample: p0(f1), p208(f1), p208([]) — correctly staged as [[p0,p208#2],[p208#1]], but misread by id-order as [[p0,p208#1],...], which do overlap). Properties (a) determinism and (b) totality already exercise duplicate ids correctly and are left unchanged; only this property's generated items are now constrained to unique ids via `fc.uniqueArray`, where the reconstruction is unambiguous. --------- Co-authored-by: sim <sim@local>
114 lines
4.8 KiB
JavaScript
114 lines
4.8 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* file-overlap-partitioner.test.cjs — Behavioral tests for the shared
|
|
* file-overlap wave partitioner (#3674), extracted from
|
|
* `claude-orchestration.cts`'s `partitionStages` (#1143).
|
|
*
|
|
* Module: gsd-core/bin/lib/file-overlap-partitioner.cjs
|
|
* Exported: partitionByFileOverlap(items: { id: string; files: string[] }[]) -> string[][]
|
|
*
|
|
* These tests pin the generic module's own contract, independent of any
|
|
* `Plan`/`Wave` shape from `claude-orchestration.cts`. Several test names
|
|
* carry a "(characterization)" suffix per the #3674 test matrix (rows 5, 6):
|
|
* they characterize `partitionStages`' original algorithm — traced from its
|
|
* source, since duplicate-id and chain-overlap inputs were never reachable
|
|
* through `emitWorkflowScript`'s public API (it validates plan ids unique
|
|
* before ever calling the partitioner) — now pinned against the extracted,
|
|
* generic entry point.
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const { partitionByFileOverlap } = require('../gsd-core/bin/lib/file-overlap-partitioner.cjs');
|
|
|
|
describe('partitionByFileOverlap', () => {
|
|
|
|
test('partitions overlapping plans into separate stages', () => {
|
|
const stages = partitionByFileOverlap([
|
|
{ id: 'p1', files: ['shared.ts'] },
|
|
{ id: 'p2', files: ['shared.ts'] },
|
|
{ id: 'p3', files: ['shared.ts'] },
|
|
]);
|
|
assert.strictEqual(stages.length, 3, 'every plan shares the same file -> each gets its own stage');
|
|
assert.deepStrictEqual(stages, [['p1'], ['p2'], ['p3']]);
|
|
});
|
|
|
|
test('coalesces disjoint plans into stage 0', () => {
|
|
const stages = partitionByFileOverlap([
|
|
{ id: 'p1', files: ['a.ts'] },
|
|
{ id: 'p2', files: ['b.ts'] },
|
|
{ id: 'p3', files: ['c.ts'] },
|
|
]);
|
|
assert.strictEqual(stages.length, 1, 'fully disjoint plans coalesce into one stage');
|
|
assert.deepStrictEqual(stages[0].slice().sort(), ['p1', 'p2', 'p3']);
|
|
});
|
|
|
|
test('an empty file set joins the first stage', () => {
|
|
const stages = partitionByFileOverlap([
|
|
{ id: 'p1', files: [] },
|
|
]);
|
|
assert.deepStrictEqual(stages, [['p1']]);
|
|
});
|
|
|
|
test('two plans with empty file sets do not collide', () => {
|
|
const stages = partitionByFileOverlap([
|
|
{ id: 'p1', files: [] },
|
|
{ id: 'p2', files: [] },
|
|
]);
|
|
assert.strictEqual(stages.length, 1, 'two empty file sets never overlap each other');
|
|
assert.deepStrictEqual(stages[0], ['p1', 'p2']);
|
|
});
|
|
|
|
test('duplicate plan ids are not deduplicated by the partitioner (characterization)', () => {
|
|
// Same id, overlapping files: each occurrence is placed independently by
|
|
// the greedy first-fit walk (id is never used as a dedup/merge key) — the
|
|
// second occurrence overlaps the first's file set and lands in stage 1.
|
|
const overlapping = partitionByFileOverlap([
|
|
{ id: 'p1', files: ['a.ts'] },
|
|
{ id: 'p1', files: ['a.ts'] },
|
|
]);
|
|
assert.deepStrictEqual(overlapping, [['p1'], ['p1']], 'both occurrences of the duplicate id are preserved, one per stage');
|
|
|
|
// Same id, disjoint files: both occurrences independently coalesce into
|
|
// stage 0 (files are what is checked for overlap, never the id).
|
|
const disjoint = partitionByFileOverlap([
|
|
{ id: 'p1', files: ['a.ts'] },
|
|
{ id: 'p1', files: ['b.ts'] },
|
|
]);
|
|
assert.deepStrictEqual(disjoint, [['p1', 'p1']], 'duplicate ids with disjoint files both land in stage 0, not merged');
|
|
});
|
|
|
|
test('chain-overlap plans produce the greedy, non-optimal assignment (characterization)', () => {
|
|
// A∩B (share f2), B∩C (share f3), A∌C (no shared file). A minimal
|
|
// assignment could place A and C together after B, but greedy first-fit
|
|
// processes in input order: A -> stage0; B overlaps A -> stage1; C does
|
|
// NOT overlap stage0 (A's files are f1,f2; C's are f3,f4) -> stage0.
|
|
const stages = partitionByFileOverlap([
|
|
{ id: 'A', files: ['f1', 'f2'] },
|
|
{ id: 'B', files: ['f2', 'f3'] },
|
|
{ id: 'C', files: ['f3', 'f4'] },
|
|
]);
|
|
assert.deepStrictEqual(stages, [['A', 'C'], ['B']], 'greedy first-fit, not optimal bin-packing');
|
|
});
|
|
|
|
test('path casing is compared by exact string, not normalized', () => {
|
|
const stages = partitionByFileOverlap([
|
|
{ id: 'p1', files: ['Foo.ts'] },
|
|
{ id: 'p2', files: ['foo.ts'] },
|
|
]);
|
|
assert.strictEqual(stages.length, 1, '"Foo.ts" and "foo.ts" are treated as distinct files -> no forced split');
|
|
assert.deepStrictEqual(stages[0], ['p1', 'p2']);
|
|
});
|
|
|
|
test('backslash and forward-slash paths are not normalized to the same file', () => {
|
|
const stages = partitionByFileOverlap([
|
|
{ id: 'p1', files: ['src\\foo.ts'] },
|
|
{ id: 'p2', files: ['src/foo.ts'] },
|
|
]);
|
|
assert.strictEqual(stages.length, 1, 'no separator normalization -> the two strings never compare equal');
|
|
assert.deepStrictEqual(stages[0], ['p1', 'p2']);
|
|
});
|
|
});
|