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.
144 lines
4.7 KiB
TypeScript
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,
|
|
};
|
|
}
|