Files
msd-core/sdk/src/query/workstream-inventory.ts
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

144 lines
4.7 KiB
TypeScript

/**
* Workstream Inventory Module.
*
* Owns discovery and read-only projection of .planning/workstreams/* state.
* Query handlers should render outputs from this inventory instead of
* rescanning workstream directories directly.
*
* Pure projection logic lives in ../workstream-inventory/builder.ts.
* This module handles I/O orchestration only.
*/
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { scanPhasePlans } from './plan-scan.js';
import { stateExtractField } from './state-document.js';
import { readActiveWorkstream } from './active-workstream-store.js';
import { buildWorkstreamInventory } from '../workstream-inventory/builder.js';
// Re-export types from the builder so downstream consumers can import from here.
export type {
WorkstreamPhaseInventory,
WorkstreamInventory,
WorkstreamInventoryList,
} from '../workstream-inventory/builder.js';
import type { WorkstreamInventory, WorkstreamInventoryList } from '../workstream-inventory/builder.js';
export const planningRoot = (projectDir: string): string =>
join(projectDir, '.planning');
export const workstreamsRoot = (projectDir: string): string =>
join(planningRoot(projectDir), 'workstreams');
function wsPlanningPaths(projectDir: string, name: string) {
const base = join(planningRoot(projectDir), 'workstreams', name);
return {
state: join(base, 'STATE.md'),
roadmap: join(base, 'ROADMAP.md'),
phases: join(base, 'phases'),
requirements: join(base, 'REQUIREMENTS.md'),
};
}
function readSubdirectories(dir: string): string[] {
if (!existsSync(dir)) return [];
return readdirSync(dir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name);
}
export function countRoadmapPhases(roadmapPath: string, fallbackCount: number): number {
try {
const roadmapContent = readFileSync(roadmapPath, 'utf-8');
const matches = roadmapContent.match(/^#{2,4}\s+Phase\s+[\w][\w.-]*/gm);
return matches ? matches.length : fallbackCount;
} catch {
return fallbackCount;
}
}
export function countPhaseFiles(phaseDir: string): { planCount: number; summaryCount: number } {
const scan = scanPhasePlans(phaseDir);
return { planCount: scan.planCount, summaryCount: scan.summaryCount };
}
function readStateProjection(statePath: string): { status: string; current_phase: string | null; last_activity: string | null } {
try {
const stateContent = readFileSync(statePath, 'utf-8');
return {
status: stateExtractField(stateContent, 'Status') || 'unknown',
current_phase: stateExtractField(stateContent, 'Current Phase'),
last_activity: stateExtractField(stateContent, 'Last Activity'),
};
} catch {
return {
status: 'unknown',
current_phase: null,
last_activity: null,
};
}
}
export function inspectWorkstream(
projectDir: string,
name: string,
options: { active?: string | null } = {},
): WorkstreamInventory | null {
const wsDir = join(workstreamsRoot(projectDir), name);
if (!existsSync(wsDir)) return null;
const activeWorkstreamName = options.active === undefined ? readActiveWorkstream(projectDir) : options.active;
const p = wsPlanningPaths(projectDir, name);
const phaseDirNames = readSubdirectories(p.phases);
// Collect per-phase file counts
const phaseFilesCounts = phaseDirNames.map(dir => {
const counts = countPhaseFiles(join(p.phases, dir));
return { directory: dir, planCount: counts.planCount, summaryCount: counts.summaryCount };
});
return buildWorkstreamInventory({
name,
projectDir,
workstreamDir: wsDir,
phaseDirNames,
activeWorkstreamName,
phaseFilesCounts,
roadmapPhaseCount: countRoadmapPhases(p.roadmap, phaseDirNames.length),
stateProjection: readStateProjection(p.state),
filesExist: {
roadmap: existsSync(p.roadmap),
state: existsSync(p.state),
requirements: existsSync(p.requirements),
},
});
}
export function listWorkstreamInventories(projectDir: string): WorkstreamInventoryList {
const wsRoot = workstreamsRoot(projectDir);
if (!existsSync(wsRoot)) {
return {
mode: 'flat',
active: null,
workstreams: [],
count: 0,
message: 'No workstreams — operating in flat mode',
};
}
const active = readActiveWorkstream(projectDir);
const entries = readdirSync(wsRoot, { withFileTypes: true });
const workstreams: WorkstreamInventory[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const inventory = inspectWorkstream(projectDir, entry.name, { active });
if (inventory) workstreams.push(inventory);
}
return {
mode: 'workstream',
active,
workstreams,
count: workstreams.length,
};
}