Files
msd-core/tests/workstream-inventory-builder-generator.test.cjs
Tom Boucher ed8f4c9a31 feat(3544): Workstream Inventory Builder/Reader split (Phase 3 of #3524)
Phase 3 of the CJS↔SDK hard-seam migration (parent #3524).
Introduces the Builder/Reader pattern for paired Modules with
mixed pure-and-I/O concerns — the template for Phase 4 and
follow-up enhancements that migrate other paired Modules.

Phase 1 and Phase 2 migrated Modules where both sides used
character-equivalent logic. Phase 3 introduces the case where
the pure logic is shareable but the I/O is legitimately per-side.
The Builder/Reader split resolves this:

- The Builder is pure — accepts pre-collected data
  (BuilderInputs struct), returns the typed projection. One
  source of truth; one generator-emitted CJS mirror. Drift
  is structurally impossible.
- The Readers are per-side hand-authored Adapters that do the
  fs reads in their native idiom (currently both sync; either
  side can go async later without touching the Builder), then
  delegate to the Builder.

- sdk/src/workstream-inventory/builder.ts — Builder source.
  170 lines. Pure. Exports buildWorkstreamInventory(inputs),
  isCompletedInventory(status), plus the three typed inventory
  interfaces (WorkstreamPhaseInventory, WorkstreamInventory,
  WorkstreamInventoryList).
- sdk/src/workstream-inventory/builder.test.ts — 18 vitest
  pinning fixtures across all status branches, progress-percent
  clamping, active-marker projection, and isCompletedInventory
  classifier.
- sdk/scripts/gen-workstream-inventory-builder.mjs — generator.
  Captures function bodies via Function.prototype.toString();
  emits with the standard GENERATED FILE banner. Includes a
  small `const relative = path.relative;` preamble in the
  output to handle ESM destructured imports in the compiled
  source.
- sdk/scripts/check-workstream-inventory-builder-fresh.mjs —
  freshness check. Imports the generator function directly
  (rather than duplicating logic) — a cleaner pattern than
  Phase 1/2's approach.
- get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs —
  generator-emitted CJS mirror.
- tests/workstream-inventory-builder-generator.test.cjs — 16
  parity assertions confirming CJS-generated output ==
  SDK source output for every fixture.

- bin/lib/workstream-inventory.cjs: 159 → 132 lines.
  Projection logic gone. `inspectWorkstream` and
  `listWorkstreamInventories` collect BuilderInputs via the
  existing sync fs functions and delegate to the Builder.
  `isCompletedInventory` re-exported from the Builder (its
  signature changed from object→string, but no external
  callers exist so the change is safe).
- sdk/src/query/workstream-inventory.ts: 196 → 143 lines.
  Same shape, sync fs (the SDK was already sync — surprise from
  recon). Types re-exported from the Builder.

- sdk/package.json: gen:workstream-inventory-builder and
  check:workstream-inventory-builder-fresh scripts.
- package.json: proxy for the freshness check.
- .githooks/pre-commit: drift block.
- .github/workflows/test.yml: drift check step.
- CONTEXT.md: amended "Workstream Inventory Module" entry
  to document the Builder/Reader split.
- docs/INVENTORY.md, docs/INVENTORY-MANIFEST.json:
  +1 module count, +1 row for the generated builder.

- Full suite: 9229/9229 pass (baseline 9215 + 14 net new from
  the parity assertions).
- Vitest: 18 Builder fixtures pass.
- Reader shrink: -27 lines on CJS, -53 lines on SDK.
- Net diff (modified files only): +68 / -133 = 65-line
  reduction. New files (Builder, generator, freshness check,
  parity test) add ~600 lines of new structured code.

1. `isCompletedInventory` signature changed from
   isCompletedInventory(inventory: object) to
   isCompletedInventory(status: string). Original CJS exported
   the object form but no external caller passed an object —
   they all passed inventory.status. Verified by grep before
   committing.
2. Generator preamble. The compiled ESM uses
   `import { relative } from 'node:path'`, making `relative`
   a free variable in `buildWorkstreamInventory`. The generator
   emits `const relative = path.relative;` so the captured
   function body works in CJS.
3. Freshness check imports the generator. The freshness check
   imports the generator's buildWorkstreamInventoryBuilderCjs()
   function directly rather than duplicating generation logic.
   Cleaner than Phase 1/2; future generators should follow this.

Shareable via the Builder/Reader pattern in future enhancements:
- frontmatter (pure YAML/markdown parsing)
- plan-scan (pure PLAN.md structure parsing)
- decisions (pure decision-record parsing)
- secrets (regex-based detection in text)
- uat (UAT-criteria parsing)

Structural divergence — different approach needed:
- state — sync vs async file ops; mutation paths differ.
- workstream — lifecycle ops; per-side API surface differs.
- phase, roadmap, init, profile-output, template — large
  surfaces; each its own potential enhancement.

None of these is in scope for Phase 3.

Closes #3544.
2026-05-15 09:35:02 -04:00

160 lines
6.0 KiB
JavaScript

'use strict';
/**
* CJS parity test — Workstream Inventory Builder generator.
*
* For every fixture, asserts that the compiled SDK ESM module and the
* generated CJS artifact produce byte-identical output.
*/
const { describe, test, before } = require('node:test');
const assert = require('node:assert/strict');
// ─── Shared fixtures ──────────────────────────────────────────────────────────
function minimalInputs(overrides = {}) {
return {
name: 'my-ws',
projectDir: '/project',
workstreamDir: '/project/.planning/workstreams/my-ws',
phaseDirNames: [],
activeWorkstreamName: null,
phaseFilesCounts: [],
roadmapPhaseCount: 0,
stateProjection: { status: 'unknown', current_phase: null, last_activity: null },
filesExist: { roadmap: false, state: false, requirements: false },
...overrides,
};
}
const FIXTURES = [
{
label: 'empty inventory: no phase dirs, no STATE.md',
inputs: minimalInputs(),
},
{
label: 'one phase in_progress (partial plan completion)',
inputs: minimalInputs({
phaseDirNames: ['01-alpha'],
phaseFilesCounts: [{ directory: '01-alpha', planCount: 3, summaryCount: 1 }],
roadmapPhaseCount: 1,
stateProjection: { status: 'executing', current_phase: '01-alpha', last_activity: '2026-05-01' },
filesExist: { roadmap: true, state: true, requirements: false },
}),
},
{
label: 'one phase complete (summary_count >= plan_count)',
inputs: minimalInputs({
phaseDirNames: ['01-alpha'],
phaseFilesCounts: [{ directory: '01-alpha', planCount: 2, summaryCount: 2 }],
roadmapPhaseCount: 1,
stateProjection: { status: 'milestone complete', current_phase: null, last_activity: '2026-04-01' },
filesExist: { roadmap: true, state: true, requirements: true },
}),
},
{
label: 'one phase pending (plan_count is 0)',
inputs: minimalInputs({
phaseDirNames: ['01-alpha'],
phaseFilesCounts: [{ directory: '01-alpha', planCount: 0, summaryCount: 0 }],
roadmapPhaseCount: 1,
stateProjection: { status: 'planning', current_phase: null, last_activity: null },
}),
},
{
label: 'multiple phases with mixed statuses',
inputs: minimalInputs({
phaseDirNames: ['01-alpha', '02-beta', '03-gamma'],
phaseFilesCounts: [
{ directory: '01-alpha', planCount: 2, summaryCount: 2 },
{ directory: '02-beta', planCount: 3, summaryCount: 1 },
{ directory: '03-gamma', planCount: 0, summaryCount: 0 },
],
roadmapPhaseCount: 3,
stateProjection: { status: 'executing', current_phase: '02-beta', last_activity: '2026-05-10' },
filesExist: { roadmap: true, state: true, requirements: false },
}),
},
{
label: 'progress_percent clamps to 100 when completedPhases > roadmapPhaseCount',
inputs: minimalInputs({
phaseDirNames: ['01-alpha', '02-beta', '03-gamma'],
phaseFilesCounts: [
{ directory: '01-alpha', planCount: 1, summaryCount: 1 },
{ directory: '02-beta', planCount: 1, summaryCount: 1 },
{ directory: '03-gamma', planCount: 1, summaryCount: 1 },
],
roadmapPhaseCount: 1,
stateProjection: { status: 'milestone complete', current_phase: null, last_activity: null },
filesExist: { roadmap: true, state: true, requirements: false },
}),
},
{
label: 'active workstream marker: active: true when activeWorkstreamName === name',
inputs: minimalInputs({
name: 'my-ws',
activeWorkstreamName: 'my-ws',
}),
},
{
label: 'active: false when activeWorkstreamName is a different workstream',
inputs: minimalInputs({
name: 'my-ws',
activeWorkstreamName: 'other-ws',
}),
},
];
const IS_COMPLETED_FIXTURES = [
{ status: 'milestone complete', expected: true },
{ status: 'Milestone Complete', expected: true },
{ status: 'archived', expected: true },
{ status: 'Archived', expected: true },
{ status: 'executing', expected: false },
{ status: 'planning', expected: false },
{ status: 'unknown', expected: false },
{ status: '', expected: false },
];
// ─── Test suite ───────────────────────────────────────────────────────────────
describe('workstream-inventory-builder generator parity (ESM dist vs generated CJS)', () => {
let sdkBuild, cjsModule;
before(async () => {
// Dynamic import of the ESM SDK dist (use pathToFileURL since we're in CJS context)
const path = require('path');
const { pathToFileURL } = require('url');
const distPath = path.resolve(__dirname, '..', 'sdk', 'dist', 'workstream-inventory', 'builder.js');
sdkBuild = await import(pathToFileURL(distPath).href);
// CJS require of the generated artifact
cjsModule = require('../get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs');
});
describe('buildWorkstreamInventory', () => {
for (const fixture of FIXTURES) {
test(fixture.label, () => {
const sdkResult = sdkBuild.buildWorkstreamInventory(fixture.inputs);
const cjsResult = cjsModule.buildWorkstreamInventory(fixture.inputs);
assert.deepStrictEqual(
cjsResult,
sdkResult,
`Parity failure for fixture "${fixture.label}"`,
);
});
}
});
describe('isCompletedInventory', () => {
for (const { status, expected } of IS_COMPLETED_FIXTURES) {
test(`isCompletedInventory("${status}") === ${expected}`, () => {
const sdkResult = sdkBuild.isCompletedInventory(status);
const cjsResult = cjsModule.isCompletedInventory(status);
assert.strictEqual(sdkResult, expected, `SDK result mismatch for "${status}"`);
assert.strictEqual(cjsResult, expected, `CJS result mismatch for "${status}"`);
assert.strictEqual(sdkResult, cjsResult, `Parity failure for "${status}"`);
});
}
});
});