From ed8f4c9a31498a7163b4be44c6db80073d4456aa Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 15 May 2026 08:40:44 -0400 Subject: [PATCH] feat(3544): Workstream Inventory Builder/Reader split (Phase 3 of #3524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .githooks/pre-commit | 4 + .github/workflows/test.yml | 5 + CONTEXT.md | 2 +- docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 3 +- ...workstream-inventory-builder.generated.cjs | 79 ++++++ .../bin/lib/workstream-inventory.cjs | 69 ++--- package.json | 1 + sdk/package.json | 2 + ...eck-workstream-inventory-builder-fresh.mjs | 31 +++ .../gen-workstream-inventory-builder.mjs | 119 +++++++++ sdk/src/query/workstream-inventory.ts | 110 +++----- sdk/src/workstream-inventory/builder.test.ts | 237 ++++++++++++++++++ sdk/src/workstream-inventory/builder.ts | 170 +++++++++++++ ...tream-inventory-builder-generator.test.cjs | 159 ++++++++++++ 15 files changed, 861 insertions(+), 131 deletions(-) create mode 100644 get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs create mode 100644 sdk/scripts/check-workstream-inventory-builder-fresh.mjs create mode 100644 sdk/scripts/gen-workstream-inventory-builder.mjs create mode 100644 sdk/src/workstream-inventory/builder.test.ts create mode 100644 sdk/src/workstream-inventory/builder.ts create mode 100644 tests/workstream-inventory-builder-generator.test.cjs diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 0d8d72f35..87abfce80 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -12,3 +12,7 @@ fi if git diff --cached --name-only | grep -Eq "^sdk/src/configuration/|^sdk/shared/config-(defaults|schema)\.manifest\.json$|^get-shit-done/bin/lib/configuration\.generated\.cjs$|^sdk/scripts/gen-configuration\.mjs$"; then npm run check:configuration-fresh fi + +if git diff --cached --name-only | grep -Eq "^sdk/src/workstream-inventory/|^get-shit-done/bin/lib/workstream-inventory-builder\.generated\.cjs$|^sdk/scripts/gen-workstream-inventory-builder\.mjs$|^sdk/scripts/check-workstream-inventory-builder-fresh\.mjs$"; then + npm run check:workstream-inventory-builder-fresh +fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 14179dad4..33808dabf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -113,6 +113,11 @@ jobs: shell: bash run: node sdk/scripts/check-configuration-fresh.mjs + - name: SDK generated workstream-inventory-builder artifact drift check + if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24 + shell: bash + run: node sdk/scripts/check-workstream-inventory-builder-fresh.mjs + - name: Run tests with coverage shell: bash run: npm run test:coverage diff --git a/CONTEXT.md b/CONTEXT.md index fa68ce2aa..fc71df1d3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -59,7 +59,7 @@ Shared CJS/SDK Module owning config load, legacy-key normalization, defaults mer Module owning `.planning` path resolution, active workstream pointer policy (`session-scoped > shared`), pointer self-heal behavior, and planning lock semantics for workstream-aware execution. ### Workstream Inventory Module -Shared CJS/SDK Module owning workstream directory discovery, per-workstream state projection, phase/plan/summary counting, roadmap-declared phase count, active marker projection, and active-workstream collision inputs. Command handlers render list/status/progress outputs from this inventory instead of rescanning `.planning/workstreams/*` directly. +Shared CJS/SDK Module owning workstream directory discovery, per-workstream state projection, phase/plan/summary counting, roadmap-declared phase count, active marker projection, and active-workstream collision inputs. Command handlers render list/status/progress outputs from this inventory instead of rescanning `.planning/workstreams/*` directly. Source of truth for the pure projection is `sdk/src/workstream-inventory/builder.ts` (a Builder Module emitted to `get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs` via the generator pattern); per-side Reader Adapters (`bin/lib/workstream-inventory.cjs` sync, `sdk/src/query/workstream-inventory.ts` async-ready) collect filesystem inputs and delegate projection to the Builder. ### Planning Path Projection Module SDK query Module owning projection from project/workstream context to concrete `.planning` paths. Policy precedence is `explicit workstream > env workstream > env project > root`. Invalid workspace context is a validation error at this seam rather than a silent fallback. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index df1aeaedd..0b1336f7d 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -315,6 +315,7 @@ "validate-command-router.cjs", "verify-command-router.cjs", "verify.cjs", + "workstream-inventory-builder.generated.cjs", "workstream-inventory.cjs", "workstream-name-policy.cjs", "workstream.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 594c056c6..54d7968c9 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -423,7 +423,8 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `validate-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools validate` | | `verify-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools verify` | | `verify.cjs` | Plan structure, phase completeness, reference, commit validation | -| `workstream-inventory.cjs` | Shared workstream inventory projection: state fields, phase/plan/summary counts, roadmap phase count, and active marker | +| `workstream-inventory.cjs` | Shared workstream inventory projection: state fields, phase/plan/summary counts, roadmap phase count, and active marker — thin orchestrator that delegates pure projection to `workstream-inventory-builder.generated.cjs` | +| `workstream-inventory-builder.generated.cjs` | GENERATED — pure workstream inventory projection builder; CJS artifact emitted from `sdk/src/workstream-inventory/builder.ts` via `sdk/scripts/gen-workstream-inventory-builder.mjs`; do not edit directly | | `workstream-name-policy.cjs` | Canonical workstream name validation (`isValidActiveWorkstreamName`) and slug normalization (`toWorkstreamSlug`); shared by all workstream callers | | `workstream.cjs` | Workstream CRUD, migration, session-scoped active pointer | | `worktree-safety.cjs` | Worktree-root resolution and non-destructive prune policy decisions; owns W017 health-check logic | diff --git a/get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs b/get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs new file mode 100644 index 000000000..5a0aed285 --- /dev/null +++ b/get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs @@ -0,0 +1,79 @@ +'use strict'; + +/** + * GENERATED FILE — DO NOT EDIT. + * + * Source: sdk/src/workstream-inventory/builder.ts + * Regenerate: cd sdk && npm run gen:workstream-inventory-builder + * + * Workstream Inventory Builder — pure projection from pre-collected + * filesystem data to typed WorkstreamInventory. No I/O. No async. + */ + +const path = require('path'); +const relative = path.relative; + +// Internal helpers +function toPosixPath(p) { + return p.split('\\').join('/'); +} + +function isCompletedInventory(status) { + const s = String(status ?? '').toLowerCase(); + return s.includes('milestone complete') || s.includes('archived'); +} + +function buildWorkstreamInventory(inputs) { + const { name, projectDir, workstreamDir, phaseDirNames, activeWorkstreamName, phaseFilesCounts, roadmapPhaseCount, stateProjection, filesExist, } = inputs; + // Index counts by directory for O(1) lookup during sort/iteration + const countsMap = new Map(); + for (const entry of phaseFilesCounts) { + countsMap.set(entry.directory, { planCount: entry.planCount, summaryCount: entry.summaryCount }); + } + const phases = []; + let completedPhases = 0; + let totalPlans = 0; + let completedPlans = 0; + for (const dir of [...phaseDirNames].sort()) { + const counts = countsMap.get(dir) ?? { planCount: 0, summaryCount: 0 }; + const status = counts.summaryCount >= counts.planCount && counts.planCount > 0 + ? 'complete' + : counts.planCount > 0 + ? 'in_progress' + : 'pending'; + totalPlans += counts.planCount; + completedPlans += Math.min(counts.summaryCount, counts.planCount); + if (status === 'complete') + completedPhases++; + phases.push({ + directory: dir, + status, + plan_count: counts.planCount, + summary_count: counts.summaryCount, + }); + } + return { + name, + path: toPosixPath(relative(projectDir, workstreamDir)), + active: name === activeWorkstreamName, + files: { + roadmap: filesExist.roadmap, + state: filesExist.state, + requirements: filesExist.requirements, + }, + status: stateProjection.status, + current_phase: stateProjection.current_phase, + last_activity: stateProjection.last_activity, + phases, + phase_count: phases.length, + completed_phases: completedPhases, + roadmap_phase_count: roadmapPhaseCount, + total_plans: totalPlans, + completed_plans: completedPlans, + progress_percent: roadmapPhaseCount > 0 + ? Math.min(100, Math.round((completedPhases / roadmapPhaseCount) * 100)) + : 0, + }; +} + +module.exports = { buildWorkstreamInventory, isCompletedInventory }; diff --git a/get-shit-done/bin/lib/workstream-inventory.cjs b/get-shit-done/bin/lib/workstream-inventory.cjs index 4ab483d69..8e84424ab 100644 --- a/get-shit-done/bin/lib/workstream-inventory.cjs +++ b/get-shit-done/bin/lib/workstream-inventory.cjs @@ -6,6 +6,9 @@ * Owns discovery and read-only projection of .planning/workstreams/* state. * Command handlers should render outputs from this inventory instead of * rescanning workstream directories directly. + * + * Pure projection logic lives in workstream-inventory-builder.generated.cjs. + * This module handles I/O orchestration only. */ const fs = require('fs'); @@ -14,6 +17,7 @@ const { toPosixPath, readSubdirectories } = require('./core.cjs'); const scanPhasePlans = require('./plan-scan.cjs'); const { planningPaths, planningRoot, getActiveWorkstream } = require('./planning-workspace.cjs'); const { stateExtractField } = require('./state-document.cjs'); +const { buildWorkstreamInventory, isCompletedInventory } = require('./workstream-inventory-builder.generated.cjs'); function workstreamsRoot(cwd) { return path.join(planningRoot(cwd), 'workstreams'); @@ -55,57 +59,31 @@ function inspectWorkstream(cwd, name, options = {}) { const wsDir = path.join(workstreamsRoot(cwd), name); if (!fs.existsSync(wsDir)) return null; - const active = options.active === undefined ? getActiveWorkstream(cwd) : options.active; + const activeWorkstreamName = options.active === undefined ? getActiveWorkstream(cwd) : options.active; const p = planningPaths(cwd, name); - const phaseDirs = readSubdirectories(p.phases); - const phases = []; - let completedPhases = 0; - let totalPlans = 0; - let completedPlans = 0; + const phaseDirNames = readSubdirectories(p.phases); - for (const dir of phaseDirs.sort()) { + // Collect per-phase file counts + const phaseFilesCounts = phaseDirNames.map(dir => { const counts = countPhaseFiles(path.join(p.phases, dir)); - const status = counts.summaryCount >= counts.planCount && counts.planCount > 0 - ? 'complete' - : counts.planCount > 0 - ? 'in_progress' - : 'pending'; + return { directory: dir, planCount: counts.planCount, summaryCount: counts.summaryCount }; + }); - totalPlans += counts.planCount; - completedPlans += Math.min(counts.summaryCount, counts.planCount); - if (status === 'complete') completedPhases++; - - phases.push({ - directory: dir, - status, - plan_count: counts.planCount, - summary_count: counts.summaryCount, - }); - } - - const roadmapPhaseCount = countRoadmapPhases(p.roadmap, phaseDirs.length); - const state = readStateProjection(p.state); - - return { + return buildWorkstreamInventory({ name, - path: toPosixPath(path.relative(cwd, wsDir)), - active: name === active, - files: { + projectDir: cwd, + workstreamDir: wsDir, + phaseDirNames, + activeWorkstreamName, + phaseFilesCounts, + roadmapPhaseCount: countRoadmapPhases(p.roadmap, phaseDirNames.length), + stateProjection: readStateProjection(p.state), + filesExist: { roadmap: fs.existsSync(p.roadmap), state: fs.existsSync(p.state), requirements: fs.existsSync(p.requirements), }, - status: state.status, - current_phase: state.current_phase, - last_activity: state.last_activity, - phases, - phase_count: phases.length, - completed_phases: completedPhases, - roadmap_phase_count: roadmapPhaseCount, - total_plans: totalPlans, - completed_plans: completedPlans, - progress_percent: roadmapPhaseCount > 0 ? Math.min(100, Math.round((completedPhases / roadmapPhaseCount) * 100)) : 0, - }; + }); } function listWorkstreamInventories(cwd) { @@ -137,15 +115,10 @@ function listWorkstreamInventories(cwd) { }; } -function isCompletedInventory(inventory) { - const status = String(inventory && inventory.status ? inventory.status : '').toLowerCase(); - return status.includes('milestone complete') || status.includes('archived'); -} - function getOtherActiveWorkstreamInventories(cwd, excludeWs) { return listWorkstreamInventories(cwd).workstreams .filter(inventory => inventory.name !== excludeWs) - .filter(inventory => !isCompletedInventory(inventory)); + .filter(inventory => !isCompletedInventory(inventory.status)); } module.exports = { diff --git a/package.json b/package.json index df63eb50e..5f3d719ec 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "check:alias-drift": "cd sdk && npm run check:alias-drift", "check:state-document-fresh": "cd sdk && npm run check:state-document-fresh", "check:configuration-fresh": "cd sdk && npm run check:configuration-fresh", + "check:workstream-inventory-builder-fresh": "cd sdk && npm run check:workstream-inventory-builder-fresh", "prepublishOnly": "npm run build:hooks && npm run build:sdk", "pretest": "npm run build:sdk && npm run lint:skill-deps", "pretest:coverage": "npm run build:sdk", diff --git a/sdk/package.json b/sdk/package.json index 060f36edc..7a930b862 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -40,6 +40,8 @@ "check:state-document-fresh": "npm run build && node scripts/check-state-document-fresh.mjs", "gen:configuration": "npm run build && node scripts/gen-configuration.mjs", "check:configuration-fresh": "npm run build && node scripts/check-configuration-fresh.mjs", + "gen:workstream-inventory-builder": "npm run build && node scripts/gen-workstream-inventory-builder.mjs", + "check:workstream-inventory-builder-fresh": "npm run build && node scripts/check-workstream-inventory-builder-fresh.mjs", "prepublishOnly": "rm -rf dist && tsc && chmod +x dist/cli.js", "test": "vitest run", "test:unit": "vitest run --project unit", diff --git a/sdk/scripts/check-workstream-inventory-builder-fresh.mjs b/sdk/scripts/check-workstream-inventory-builder-fresh.mjs new file mode 100644 index 000000000..e48403c10 --- /dev/null +++ b/sdk/scripts/check-workstream-inventory-builder-fresh.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/** + * Freshness check for workstream-inventory-builder.generated.cjs. + * + * Regenerates the expected CJS content in-memory (without writing to disk) and + * compares it to the committed file. Exits 0 if they match, 1 if stale. + * + * Run: node sdk/scripts/check-workstream-inventory-builder-fresh.mjs + * (Requires sdk/dist to be built first — `npm run build` in sdk/.) + */ + +import { readFile } from 'node:fs/promises'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildWorkstreamInventoryBuilderCjs } from './gen-workstream-inventory-builder.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); + +const expected = await buildWorkstreamInventoryBuilderCjs(); + +const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'workstream-inventory-builder.generated.cjs'); +const committed = await readFile(committedPath, 'utf-8'); + +if (expected === committed) { + console.log('workstream-inventory-builder.generated.cjs is fresh'); + process.exit(0); +} else { + console.error('workstream-inventory-builder.generated.cjs is STALE.'); + console.error('Regenerate: cd sdk && npm run gen:workstream-inventory-builder'); + process.exit(1); +} diff --git a/sdk/scripts/gen-workstream-inventory-builder.mjs b/sdk/scripts/gen-workstream-inventory-builder.mjs new file mode 100644 index 000000000..26b8da3a5 --- /dev/null +++ b/sdk/scripts/gen-workstream-inventory-builder.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * Generator for the Workstream Inventory Builder CJS artifact. + * + * Reads the compiled ESM output from sdk/dist/workstream-inventory/builder.js, + * extracts function source via Function.prototype.toString() for exports + * and via source-text extraction for internal helpers, then emits + * get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs. + * + * Run: cd sdk && npm run gen:workstream-inventory-builder + * Freshness check: node sdk/scripts/check-workstream-inventory-builder-fresh.mjs + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +export const BANNER = `'use strict'; + +/** + * GENERATED FILE — DO NOT EDIT. + * + * Source: sdk/src/workstream-inventory/builder.ts + * Regenerate: cd sdk && npm run gen:workstream-inventory-builder + * + * Workstream Inventory Builder — pure projection from pre-collected + * filesystem data to typed WorkstreamInventory. No I/O. No async. + */ + +`; + +/** + * Extract a top-level function declaration (non-exported) from a JS source + * string by scanning for `function (` and capturing the entire body + * including balanced braces. + */ +export function extractFunctionFromSource(source, name) { + const marker = `function ${name}(`; + const start = source.indexOf(marker); + if (start === -1) { + throw new Error(`Could not find function ${name} in compiled source`); + } + // Find the opening brace + const braceOpen = source.indexOf('{', start); + if (braceOpen === -1) { + throw new Error(`Could not find opening brace for function ${name}`); + } + // Walk forward counting braces until balanced + let depth = 0; + let i = braceOpen; + for (; i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}') { + depth--; + if (depth === 0) break; + } + } + if (depth !== 0) { + throw new Error(`Could not find closing brace for function ${name}`); + } + // Return from `function name(` through the closing `}` + return source.slice(start, i + 1); +} + +export async function buildWorkstreamInventoryBuilderCjs() { + // Load the compiled ESM module to get exports via Function.prototype.toString() + const distUrl = new URL('../dist/workstream-inventory/builder.js', import.meta.url); + const { + buildWorkstreamInventory, + isCompletedInventory, + } = await import(distUrl.href); + + // Also read the compiled JS as text to extract non-exported helpers + const compiledSource = await readFile(fileURLToPath(distUrl), 'utf-8'); + + // Extract non-exported helpers from source text + const toPosixPathBody = extractFunctionFromSource(compiledSource, 'toPosixPath'); + + // Get exported function bodies via Function.prototype.toString() + const isCompletedInventoryBody = isCompletedInventory.toString(); + const buildWorkstreamInventoryBody = buildWorkstreamInventory.toString(); + + const parts = [ + BANNER.trimEnd(), + '', + "const path = require('path');", + 'const relative = path.relative;', + '', + '// Internal helpers', + toPosixPathBody, + '', + isCompletedInventoryBody, + '', + buildWorkstreamInventoryBody, + '', + 'module.exports = { buildWorkstreamInventory, isCompletedInventory };', + '', + ]; + + return parts.join('\n'); +} + +async function main() { + const content = await buildWorkstreamInventoryBuilderCjs(); + const outPath = fileURLToPath( + new URL('../../get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs', import.meta.url), + ); + await writeFile(outPath, content, 'utf-8'); + console.log(`Written: ${outPath}`); +} + +// Only run main() when this file is the entry point, not when imported. +const scriptPath = fileURLToPath(import.meta.url); +const entryPath = process.argv[1] ? new URL(process.argv[1], 'file://').pathname : ''; +if (scriptPath === entryPath || process.argv[1] === scriptPath) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/sdk/src/query/workstream-inventory.ts b/sdk/src/query/workstream-inventory.ts index ecc81c303..c5650dadd 100644 --- a/sdk/src/query/workstream-inventory.ts +++ b/sdk/src/query/workstream-inventory.ts @@ -4,51 +4,26 @@ * 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, relative } from 'node:path'; - -import { toPosixPath } from './helpers.js'; +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'; -export interface WorkstreamPhaseInventory { - directory: string; - status: 'complete' | 'in_progress' | 'pending'; - plan_count: number; - summary_count: number; -} +// Re-export types from the builder so downstream consumers can import from here. +export type { + WorkstreamPhaseInventory, + WorkstreamInventory, + WorkstreamInventoryList, +} from '../workstream-inventory/builder.js'; -export interface WorkstreamInventory { - name: string; - path: string; - active: boolean; - files: { - roadmap: boolean; - state: boolean; - requirements: boolean; - }; - status: string; - current_phase: string | null; - last_activity: string | null; - phases: WorkstreamPhaseInventory[]; - phase_count: number; - completed_phases: number; - roadmap_phase_count: number; - total_plans: number; - completed_plans: number; - progress_percent: number; -} - -export interface WorkstreamInventoryList { - mode: 'flat' | 'workstream'; - active: string | null; - workstreams: WorkstreamInventory[]; - count: number; - message?: string; -} +import type { WorkstreamInventory, WorkstreamInventoryList } from '../workstream-inventory/builder.js'; export const planningRoot = (projectDir: string): string => join(projectDir, '.planning'); @@ -86,7 +61,7 @@ export function countPhaseFiles(phaseDir: string): { planCount: number; summaryC return { planCount: scan.planCount, summaryCount: scan.summaryCount }; } -function readStateProjection(statePath: string): Pick { +function readStateProjection(statePath: string): { status: string; current_phase: string | null; last_activity: string | null } { try { const stateContent = readFileSync(statePath, 'utf-8'); return { @@ -111,58 +86,31 @@ export function inspectWorkstream( const wsDir = join(workstreamsRoot(projectDir), name); if (!existsSync(wsDir)) return null; - const active = options.active === undefined ? readActiveWorkstream(projectDir) : options.active; + const activeWorkstreamName = options.active === undefined ? readActiveWorkstream(projectDir) : options.active; const p = wsPlanningPaths(projectDir, name); - const phaseDirs = readSubdirectories(p.phases); - const phases: WorkstreamPhaseInventory[] = []; - let completedPhases = 0; - let totalPlans = 0; - let completedPlans = 0; + const phaseDirNames = readSubdirectories(p.phases); - for (const dir of [...phaseDirs].sort()) { + // Collect per-phase file counts + const phaseFilesCounts = phaseDirNames.map(dir => { const counts = countPhaseFiles(join(p.phases, dir)); - const status: WorkstreamPhaseInventory['status'] = - counts.summaryCount >= counts.planCount && counts.planCount > 0 - ? 'complete' - : counts.planCount > 0 - ? 'in_progress' - : 'pending'; + return { directory: dir, planCount: counts.planCount, summaryCount: counts.summaryCount }; + }); - totalPlans += counts.planCount; - completedPlans += Math.min(counts.summaryCount, counts.planCount); - if (status === 'complete') completedPhases++; - - phases.push({ - directory: dir, - status, - plan_count: counts.planCount, - summary_count: counts.summaryCount, - }); - } - - const roadmapPhaseCount = countRoadmapPhases(p.roadmap, phaseDirs.length); - const state = readStateProjection(p.state); - - return { + return buildWorkstreamInventory({ name, - path: toPosixPath(relative(projectDir, wsDir)), - active: name === active, - files: { + 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), }, - status: state.status, - current_phase: state.current_phase, - last_activity: state.last_activity, - phases, - phase_count: phases.length, - completed_phases: completedPhases, - roadmap_phase_count: roadmapPhaseCount, - total_plans: totalPlans, - completed_plans: completedPlans, - progress_percent: roadmapPhaseCount > 0 ? Math.min(100, Math.round((completedPhases / roadmapPhaseCount) * 100)) : 0, - }; + }); } export function listWorkstreamInventories(projectDir: string): WorkstreamInventoryList { diff --git a/sdk/src/workstream-inventory/builder.test.ts b/sdk/src/workstream-inventory/builder.test.ts new file mode 100644 index 000000000..34cdb275c --- /dev/null +++ b/sdk/src/workstream-inventory/builder.test.ts @@ -0,0 +1,237 @@ +/** + * Pinning tests for the Workstream Inventory Builder. + * + * The Builder is pure (no I/O). It takes pre-collected BuilderInputs and + * returns a WorkstreamInventory. These tests lock the projection logic against + * the canonical shape defined in sdk/src/query/workstream-inventory.ts. + */ + +import { describe, it, expect } from 'vitest'; +import { buildWorkstreamInventory, isCompletedInventory } from './builder.js'; +import type { BuilderInputs } from './builder.js'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function minimalInputs(overrides: Partial = {}): BuilderInputs { + 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, + }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('buildWorkstreamInventory', () => { + it('empty inventory: no phase dirs, no STATE.md', () => { + const result = buildWorkstreamInventory(minimalInputs()); + expect(result).toEqual({ + name: 'my-ws', + path: '.planning/workstreams/my-ws', + active: false, + files: { roadmap: false, state: false, requirements: false }, + status: 'unknown', + current_phase: null, + last_activity: null, + phases: [], + phase_count: 0, + completed_phases: 0, + roadmap_phase_count: 0, + total_plans: 0, + completed_plans: 0, + progress_percent: 0, + }); + }); + + it('one phase in_progress (partial plan completion)', () => { + const result = buildWorkstreamInventory( + 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 }, + }), + ); + expect(result.phases).toHaveLength(1); + expect(result.phases[0]).toEqual({ + directory: '01-alpha', + status: 'in_progress', + plan_count: 3, + summary_count: 1, + }); + expect(result.phase_count).toBe(1); + expect(result.completed_phases).toBe(0); + expect(result.total_plans).toBe(3); + expect(result.completed_plans).toBe(1); + expect(result.progress_percent).toBe(0); + expect(result.status).toBe('executing'); + expect(result.current_phase).toBe('01-alpha'); + expect(result.last_activity).toBe('2026-05-01'); + expect(result.files).toEqual({ roadmap: true, state: true, requirements: false }); + }); + + it('one phase complete (summary_count >= plan_count)', () => { + const result = buildWorkstreamInventory( + 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 }, + }), + ); + expect(result.phases[0].status).toBe('complete'); + expect(result.completed_phases).toBe(1); + expect(result.progress_percent).toBe(100); + expect(result.completed_plans).toBe(2); + }); + + it('one phase pending (plan_count is 0)', () => { + const result = buildWorkstreamInventory( + minimalInputs({ + phaseDirNames: ['01-alpha'], + phaseFilesCounts: [{ directory: '01-alpha', planCount: 0, summaryCount: 0 }], + roadmapPhaseCount: 1, + stateProjection: { status: 'planning', current_phase: null, last_activity: null }, + }), + ); + expect(result.phases[0].status).toBe('pending'); + expect(result.completed_phases).toBe(0); + expect(result.progress_percent).toBe(0); + }); + + it('multiple phases with mixed statuses', () => { + const result = buildWorkstreamInventory( + minimalInputs({ + phaseDirNames: ['01-alpha', '02-beta', '03-gamma'], + phaseFilesCounts: [ + { directory: '01-alpha', planCount: 2, summaryCount: 2 }, // complete + { directory: '02-beta', planCount: 3, summaryCount: 1 }, // in_progress + { directory: '03-gamma', planCount: 0, summaryCount: 0 }, // pending + ], + roadmapPhaseCount: 3, + stateProjection: { status: 'executing', current_phase: '02-beta', last_activity: '2026-05-10' }, + filesExist: { roadmap: true, state: true, requirements: false }, + }), + ); + expect(result.phases).toHaveLength(3); + expect(result.phases[0].status).toBe('complete'); + expect(result.phases[1].status).toBe('in_progress'); + expect(result.phases[2].status).toBe('pending'); + expect(result.completed_phases).toBe(1); + expect(result.total_plans).toBe(5); + expect(result.completed_plans).toBe(3); // 2 from alpha + min(1,3)=1 from beta + 0 from gamma + expect(result.progress_percent).toBe(Math.round((1 / 3) * 100)); // 33 + expect(result.roadmap_phase_count).toBe(3); + }); + + it('progress_percent clamps to 100 when completedPhases > roadmapPhaseCount', () => { + // 3 phase dirs all complete, but roadmap only has 1 entry + const result = buildWorkstreamInventory( + 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 }, + }), + ); + expect(result.completed_phases).toBe(3); + expect(result.roadmap_phase_count).toBe(1); + expect(result.progress_percent).toBe(100); + }); + + it('active workstream marker: active: true when activeWorkstreamName === name', () => { + const result = buildWorkstreamInventory( + minimalInputs({ + name: 'my-ws', + activeWorkstreamName: 'my-ws', + }), + ); + expect(result.active).toBe(true); + }); + + it('active: false when activeWorkstreamName is a different workstream', () => { + const result = buildWorkstreamInventory( + minimalInputs({ + name: 'my-ws', + activeWorkstreamName: 'other-ws', + }), + ); + expect(result.active).toBe(false); + }); + + it('phases are sorted by directory name', () => { + // Provide dirs in reverse order to verify sorting + const result = buildWorkstreamInventory( + minimalInputs({ + phaseDirNames: ['03-gamma', '01-alpha', '02-beta'], + phaseFilesCounts: [ + { directory: '03-gamma', planCount: 1, summaryCount: 0 }, + { directory: '01-alpha', planCount: 1, summaryCount: 1 }, + { directory: '02-beta', planCount: 1, summaryCount: 0 }, + ], + roadmapPhaseCount: 3, + stateProjection: { status: 'executing', current_phase: null, last_activity: null }, + filesExist: { roadmap: true, state: false, requirements: false }, + }), + ); + expect(result.phases.map((p) => p.directory)).toEqual(['01-alpha', '02-beta', '03-gamma']); + }); + + it('path is relative from projectDir to workstreamDir using posix separators', () => { + const result = buildWorkstreamInventory( + minimalInputs({ + projectDir: '/home/user/project', + workstreamDir: '/home/user/project/.planning/workstreams/my-ws', + }), + ); + expect(result.path).toBe('.planning/workstreams/my-ws'); + }); +}); + +describe('isCompletedInventory', () => { + it('returns true for "milestone complete"', () => { + expect(isCompletedInventory('milestone complete')).toBe(true); + }); + + it('returns true for "Milestone Complete" (case-insensitive)', () => { + expect(isCompletedInventory('Milestone Complete')).toBe(true); + }); + + it('returns true for "archived"', () => { + expect(isCompletedInventory('archived')).toBe(true); + }); + + it('returns true for "Archived"', () => { + expect(isCompletedInventory('Archived')).toBe(true); + }); + + it('returns false for "executing"', () => { + expect(isCompletedInventory('executing')).toBe(false); + }); + + it('returns false for "planning"', () => { + expect(isCompletedInventory('planning')).toBe(false); + }); + + it('returns false for "unknown"', () => { + expect(isCompletedInventory('unknown')).toBe(false); + }); + + it('returns false for empty string', () => { + expect(isCompletedInventory('')).toBe(false); + }); +}); diff --git a/sdk/src/workstream-inventory/builder.ts b/sdk/src/workstream-inventory/builder.ts new file mode 100644 index 000000000..20c065dc1 --- /dev/null +++ b/sdk/src/workstream-inventory/builder.ts @@ -0,0 +1,170 @@ +/** + * Workstream Inventory Builder — pure projection from pre-collected + * filesystem data to typed WorkstreamInventory. No I/O. No async. + * + * The caller is responsible for collecting BuilderInputs from the filesystem + * (or from test fixtures). This module performs only the stateless transformation. + */ + +import { join, relative } from 'node:path'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface WorkstreamPhaseInventory { + directory: string; + status: 'complete' | 'in_progress' | 'pending'; + plan_count: number; + summary_count: number; +} + +export interface WorkstreamInventory { + name: string; + path: string; + active: boolean; + files: { + roadmap: boolean; + state: boolean; + requirements: boolean; + }; + status: string; + current_phase: string | null; + last_activity: string | null; + phases: WorkstreamPhaseInventory[]; + phase_count: number; + completed_phases: number; + roadmap_phase_count: number; + total_plans: number; + completed_plans: number; + progress_percent: number; +} + +export interface WorkstreamInventoryList { + mode: 'flat' | 'workstream'; + active: string | null; + workstreams: WorkstreamInventory[]; + count: number; + message?: string; +} + +// ─── Inputs ─────────────────────────────────────────────────────────────────── + +export interface BuilderInputs { + /** The workstream name (directory basename). */ + name: string; + /** Absolute path to the project root. */ + projectDir: string; + /** Absolute path to the workstream directory. */ + workstreamDir: string; + /** List of phase directory names (unsorted; builder will sort them). */ + phaseDirNames: string[]; + /** The currently active workstream name, or null if none. */ + activeWorkstreamName: string | null; + /** + * Pre-collected plan/summary counts per phase directory. + * The `directory` field must match entries in `phaseDirNames`. + */ + phaseFilesCounts: Array<{ directory: string; planCount: number; summaryCount: number }>; + /** Phase count from the ROADMAP.md (already resolved, fallback applied). */ + roadmapPhaseCount: number; + /** Projection from the workstream's STATE.md (already read). */ + stateProjection: { status: string; current_phase: string | null; last_activity: string | null }; + /** Whether each canonical file exists (already checked). */ + filesExist: { roadmap: boolean; state: boolean; requirements: boolean }; +} + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +/** + * Convert a path to POSIX format (forward slashes only). + * Pure string transform — no filesystem access. + */ +function toPosixPath(p: string): string { + return p.split('\\').join('/'); +} + +// ─── Exports ────────────────────────────────────────────────────────────────── + +/** + * Pure classifier: returns true if the given status string indicates a + * completed or archived workstream (case-insensitive substring match). + */ +export function isCompletedInventory(status: string): boolean { + const s = String(status ?? '').toLowerCase(); + return s.includes('milestone complete') || s.includes('archived'); +} + +/** + * Build a WorkstreamInventory from pre-collected BuilderInputs. + * + * This is a pure function — it does not read the filesystem and does not + * produce side-effects. All I/O must be done by the caller before invoking. + */ +export function buildWorkstreamInventory(inputs: BuilderInputs): WorkstreamInventory { + const { + name, + projectDir, + workstreamDir, + phaseDirNames, + activeWorkstreamName, + phaseFilesCounts, + roadmapPhaseCount, + stateProjection, + filesExist, + } = inputs; + + // Index counts by directory for O(1) lookup during sort/iteration + const countsMap = new Map(); + for (const entry of phaseFilesCounts) { + countsMap.set(entry.directory, { planCount: entry.planCount, summaryCount: entry.summaryCount }); + } + + const phases: WorkstreamPhaseInventory[] = []; + let completedPhases = 0; + let totalPlans = 0; + let completedPlans = 0; + + for (const dir of [...phaseDirNames].sort()) { + const counts = countsMap.get(dir) ?? { planCount: 0, summaryCount: 0 }; + const status: WorkstreamPhaseInventory['status'] = + counts.summaryCount >= counts.planCount && counts.planCount > 0 + ? 'complete' + : counts.planCount > 0 + ? 'in_progress' + : 'pending'; + + totalPlans += counts.planCount; + completedPlans += Math.min(counts.summaryCount, counts.planCount); + if (status === 'complete') completedPhases++; + + phases.push({ + directory: dir, + status, + plan_count: counts.planCount, + summary_count: counts.summaryCount, + }); + } + + return { + name, + path: toPosixPath(relative(projectDir, workstreamDir)), + active: name === activeWorkstreamName, + files: { + roadmap: filesExist.roadmap, + state: filesExist.state, + requirements: filesExist.requirements, + }, + status: stateProjection.status, + current_phase: stateProjection.current_phase, + last_activity: stateProjection.last_activity, + phases, + phase_count: phases.length, + completed_phases: completedPhases, + roadmap_phase_count: roadmapPhaseCount, + total_plans: totalPlans, + completed_plans: completedPlans, + progress_percent: + roadmapPhaseCount > 0 + ? Math.min(100, Math.round((completedPhases / roadmapPhaseCount) * 100)) + : 0, + }; +} diff --git a/tests/workstream-inventory-builder-generator.test.cjs b/tests/workstream-inventory-builder-generator.test.cjs new file mode 100644 index 000000000..feb60da59 --- /dev/null +++ b/tests/workstream-inventory-builder-generator.test.cjs @@ -0,0 +1,159 @@ +'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}"`); + }); + } + }); +});