Merge pull request #3548 from gsd-build/feat/3544-workstream-inventory-builder
feat(3544): Workstream Inventory Builder/Reader split (Phase 3 of #3524)
This commit is contained in:
5
.changeset/3544-workstream-inventory-builder.md
Normal file
5
.changeset/3544-workstream-inventory-builder.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 3548
|
||||
---
|
||||
**Adds a generated Workstream Inventory Builder seam with freshness guards** — introduces `sdk/src/workstream-inventory/builder.ts` (and tests) as the canonical builder source, generates `get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs`, wires `check-workstream-inventory-builder-fresh` into hooks/CI, and updates inventory docs/manifests so SDK and installer-facing artifacts stay synchronized.
|
||||
@@ -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
|
||||
|
||||
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -360,7 +360,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
|
||||
|
||||
---
|
||||
|
||||
## CLI Modules (61 shipped)
|
||||
## CLI Modules (62 shipped)
|
||||
|
||||
Full listing: `get-shit-done/bin/lib/*.cjs`.
|
||||
|
||||
@@ -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-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-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-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 |
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
31
sdk/scripts/check-workstream-inventory-builder-fresh.mjs
Normal file
31
sdk/scripts/check-workstream-inventory-builder-fresh.mjs
Normal file
@@ -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);
|
||||
}
|
||||
119
sdk/scripts/gen-workstream-inventory-builder.mjs
Normal file
119
sdk/scripts/gen-workstream-inventory-builder.mjs
Normal file
@@ -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 <name>(` 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);
|
||||
});
|
||||
}
|
||||
@@ -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<WorkstreamInventory, 'status' | 'current_phase' | 'last_activity'> {
|
||||
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 {
|
||||
|
||||
237
sdk/src/workstream-inventory/builder.test.ts
Normal file
237
sdk/src/workstream-inventory/builder.test.ts
Normal file
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
170
sdk/src/workstream-inventory/builder.ts
Normal file
170
sdk/src/workstream-inventory/builder.ts
Normal file
@@ -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 { 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<string, { planCount: number; summaryCount: number }>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
159
tests/workstream-inventory-builder-generator.test.cjs
Normal file
159
tests/workstream-inventory-builder-generator.test.cjs
Normal file
@@ -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}"`);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user