Files
msd-core/src/workstream-inventory-builder.cts
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

466 lines
22 KiB
TypeScript

/**
* Workstream Inventory Builder — pure projection from pre-collected
* filesystem data to typed WorkstreamInventory. No I/O. No async.
*
* ADR-457 build-at-publish: the hand-written
* bin/lib/workstream-inventory-builder.cjs collapsed to a TypeScript source
* of truth. Behaviour is preserved byte-for-behaviour from the prior
* hand-written .cjs; only types are added.
*/
import path from 'node:path';
import { clampPercent } from './phase-lifecycle.cjs';
// Internal helpers
function toPosixPath(p: string): string {
return p.split('\\').join('/');
}
// #2562/#2645's FAILING_VERIFICATION_STATUSES set (the verdicts that used to
// disqualify a phase from `complete` when combined with a local
// summary-count-meets-plan-count check) was removed by ADR-3180 §7.4
// (#3186): `complete` is now the single canonical owner's verdict
// (`PhaseFilesCount.complete`, computed via `isPhaseComplete` by the
// I/O-capable caller — see the loop below), which already requires
// `verification.status === 'passed'` unconditionally. Disk-strict (#2957)
// deliberately DROPS the prior "verifier-disabled projects fall back to
// summaries-met" tolerance that set existed to preserve — a phase with no
// `*-VERIFICATION.md` (`missing`) is no longer treated as complete just
// because its summaries meet its plan count. Disclosed in this phase's
// changeset.
/**
* #2562 / Bug #2445 / #2645 review: pick ONE winning item per key from a
* PRE-SORTED list — newest `mtimeMs` wins; on an exact tie the incumbent
* (first-in-sort-order) wins, since only a STRICTLY greater mtime replaces
* it. `includeItem` lets a caller exclude items before comparison (e.g.
* out-of-milestone directories) — critically, the filter runs BEFORE the
* mtime comparison, so an excluded item can never win a tie or a comparison
* against an included one.
*
* Extracted as the SINGLE shared implementation after a #2645 review found
* two independently-written copies of this exact rule had silently
* diverged: `workstream-inventory.cts`'s ledger-winner selection compared
* raw mtimes with no scoping filter, while this module's own `rollupDirByKey`
* (below) filtered out-of-milestone directories first. A stale out-of-
* milestone directory with a newer mtime than the live in-milestone one
* (plausible after a checkout/rebase resets mtimes) could then win the
* LEDGER's selection while losing the BUILDER's — reopening #2645's own
* hole for the phase that actually counts toward `completed_phases`,
* reachable with a plain `rm` and no ledger tampering. A comment asserting
* two hand-written copies "use the same rule" is not a guarantee they do;
* one shared function is.
*/
export function pickRollupWinners<T>(
sortedItems: T[],
keyOf: (item: T) => string,
mtimeOf: (item: T) => number,
includeItem: (item: T) => boolean = () => true,
): Map<string, T> {
const winners = new Map<string, T>();
for (const item of sortedItems) {
if (!includeItem(item)) continue;
const key = keyOf(item);
const incumbent = winners.get(key);
if (incumbent === undefined || mtimeOf(item) > mtimeOf(incumbent)) {
winners.set(key, item);
}
}
return winners;
}
export function isCompletedInventory(status: unknown): boolean {
const s = (typeof status === 'string'
? status
: typeof status === 'number' || typeof status === 'boolean'
? String(status)
: ''
).trim().toLowerCase();
return /\bmilestone\s+complete\b/.test(s) || /\barchived\b/.test(s);
}
export interface PhaseFilesCount {
directory: string;
planCount: number;
summaryCount: number;
/**
* #2562: the directory's canonical phase key (`phaseKeyFromDir`). Two stale
* same-numbered directories (`05-x` alongside `5-x-old` — Bug #2445's
* scenario) share a key and must count ONCE in the rollup, or the numerator
* outgrows a denominator that counts distinct phases. Absent → the directory
* name is its own key (no de-duplication).
*/
phaseKey?: string;
/** #2562: directory mtime, the Bug #2445 tie-break when two directories share a phase key. */
mtimeMs?: number;
/**
* #2562: whether this phase directory belongs to the CURRENT milestone.
* Only meaningful when milestone scoping is active (see
* `currentMilestonePhaseCount`); undefined/true otherwise.
*/
inMilestone?: boolean;
/**
* #2562: the phase's `*-VERIFICATION.md` verdict (`readVerificationStatus`).
* Informational only as of #3186 — see `complete` below for the field this
* builder actually derives `PhaseStatus.status` from.
*/
verificationStatus?: string;
/**
* ADR-3180 §7.4 (#3186): the phase's completion verdict from the single
* canonical owner (`isPhaseComplete`, src/verification.cts), computed by
* the I/O-capable CALLER (this module is a pure, I/O-free projection and
* cannot call the owner itself — see the module header). Absent/undefined
* is treated as not-complete (`?? false`), never as "unknown → complete".
*/
complete?: boolean;
}
export interface PhaseStatus {
directory: string;
status: 'complete' | 'in_progress' | 'pending';
plan_count: number;
summary_count: number;
}
export interface WorkstreamFilesExist {
roadmap: boolean;
state: boolean;
requirements: boolean;
}
export interface StateProjection {
status: string;
current_phase: string | null | undefined;
last_activity: string | null | undefined;
}
/**
* Which authoritative signal claimed the current milestone is shipped.
*
* - `snapshot` — `milestones/<version>-ROADMAP.md` exists. Written by
* `milestone complete`; the strongest signal a tool can produce.
* - `heading` — the LIVE ROADMAP carries a shipped marker for the current
* milestone. Operator-typed prose; the weakest signal.
* - `legacy` — the over-broad project-lifetime fallback used only when the
* current milestone version cannot be determined (#1913 protection for
* malformed/legacy projects).
* - `null` — no shipped signal.
*/
export type MilestoneShippedSignal = 'snapshot' | 'heading' | 'legacy' | null;
export interface BuildWorkstreamInventoryInputs {
name: string;
projectDir: string;
workstreamDir: string;
phaseDirNames: string[];
activeWorkstreamName: string;
phaseFilesCounts: PhaseFilesCount[];
roadmapPhaseCount: number;
stateProjection: StateProjection;
filesExist: WorkstreamFilesExist;
/**
* True when an authoritative shipped signal is present for this workstream
* (an archived milestone snapshot under milestones/, or a SHIPPED marker in
* the workstream ROADMAP). When true, the inventory status is DERIVED as
* "milestone complete" regardless of the mutable STATE.md `Status` field,
* so a stale field can never report a shipped workstream as executing (#1913).
*/
milestoneShipped?: boolean;
/**
* #2562 review: WHICH shipped signal fired, so the builder can cross-validate
* it against the milestone's own artifacts at the right strength. The two
* signals are not interchangeable — see the `shippedContradicted` block below.
* Supersedes the `milestoneShipped` boolean; a caller passing only the boolean
* is treated as `'legacy'` (ungated), preserving pre-review behavior.
*/
milestoneShippedSignal?: MilestoneShippedSignal;
/**
* #2562: number of phases the CURRENT milestone declares in the ROADMAP
* `## Progress` table (including phases declared but never scaffolded). When
* > 0, milestone scoping is active: only phases whose directory belongs to
* the current milestone (`PhaseFilesCount.inMilestone`) feed the completion
* rollup, and this value — not `roadmapPhaseCount` — is the denominator, so
* completed prior-milestone phases can never inflate the percentage to 100.
* 0 disables scoping and preserves the legacy `roadmapPhaseCount` behavior.
*/
currentMilestonePhaseCount?: number;
/**
* #2562: whether milestone scoping is active, stated by the caller rather than
* inferred from `currentMilestonePhaseCount > 0`. The two are not equivalent:
* a current milestone that is declared but not yet populated is scoped AND has
* zero phases, and inferring from the count alone reads it as "unscoped" —
* which silently falls back to counting the project's entire phase history as
* the current milestone's, reporting 100% for a milestone with no work done.
* Defaults to the count-derived value so pre-#2562 callers are unaffected.
*/
milestoneScoped?: boolean;
}
export interface WorkstreamInventory {
name: string;
path: string;
active: boolean;
files: WorkstreamFilesExist;
status: string;
/**
* Whether `status` was derived ("derived") or taken verbatim from the STATE.md
* field ("field"). `"derived"` covers TWO cases and does NOT imply
* `status === 'milestone complete'`: a shipped signal fired and was accepted,
* OR a shipped signal was refused by the artifacts and the STATE field claimed
* completion anyway, so `status` was derived DOWN to `'in_progress'`. Check
* `milestone_shipped_unverified` to tell them apart.
*/
status_source: 'field' | 'derived';
/** True when the derived status disagrees with the STATE.md `Status` field (the field is stale). */
status_conflict: boolean;
/**
* #2562 review: a shipped signal fired for the current milestone but the
* milestone's own artifacts contradict it, so the claim was NOT trusted.
* Distinct from `status_conflict`, which reports the derived-vs-STATE-field
* disagreement. Without this the rejection would be silent: `status` falls
* back to the field and no output says a shipped marker was seen and refused.
*/
milestone_shipped_unverified: boolean;
current_phase: string | null | undefined;
last_activity: string | null | undefined;
phases: PhaseStatus[];
phase_count: number;
completed_phases: number;
roadmap_phase_count: number;
total_plans: number;
completed_plans: number;
progress_percent: number;
}
export function buildWorkstreamInventory(inputs: BuildWorkstreamInventoryInputs): WorkstreamInventory {
const {
name,
projectDir,
workstreamDir,
phaseDirNames,
activeWorkstreamName,
phaseFilesCounts,
roadmapPhaseCount,
stateProjection,
filesExist,
milestoneShipped,
milestoneShippedSignal,
currentMilestonePhaseCount = 0,
milestoneScoped,
} = inputs;
// A caller that passes only the legacy boolean states THAT a signal fired but
// not which one. Treat it as `legacy` — the ungated strength — so pre-review
// callers keep their exact behavior rather than silently acquiring a new gate.
const shippedSignal: MilestoneShippedSignal =
milestoneShippedSignal !== undefined ? milestoneShippedSignal : (milestoneShipped ? 'legacy' : null);
const milestoneShippedResolved = shippedSignal !== null;
// #2562: when scoping is active, prior-milestone phase directories are
// excluded from the completion rollup and the denominator. The caller states
// this; the count-derived default is the pre-#2562 fallback for callers that
// do not, and cannot represent a scoped-but-empty current milestone.
const scoped = milestoneScoped ?? currentMilestonePhaseCount > 0;
// Index counts by directory for O(1) lookup during sort/iteration
const countsMap = new Map<string, PhaseFilesCount>();
for (const entry of phaseFilesCounts) {
countsMap.set(entry.directory, entry);
}
// #2562 / Bug #2445: pick ONE directory per phase key for the rollup. Stale
// same-numbered directories left over from a prior milestone would otherwise
// each add to the numerator while the denominator counts distinct phases —
// pushing completed_phases past it, where the old `Math.min` cap silently
// rounded the result up to 100% and hid an unstarted phase. Newest-on-disk
// wins, mirroring state.cts's #2445 de-duplication. `pickRollupWinners` is
// the SHARED implementation `workstream-inventory.cts`'s ledger-winner
// selection also calls, so the two can never independently diverge again
// (#2645 review).
const rollupDirByKey = pickRollupWinners(
[...phaseDirNames].sort(),
(dir) => countsMap.get(dir)?.phaseKey ?? dir,
(dir) => countsMap.get(dir)?.mtimeMs ?? 0,
(dir) => !(scoped && countsMap.get(dir)?.inMilestone === false),
);
const rollupDirs = new Set(rollupDirByKey.values());
const phases: PhaseStatus[] = [];
let completedPhases = 0;
let totalPlans = 0;
let completedPlans = 0;
// #2562 review: in-milestone phase directories still present under `phases/`,
// whatever their status. A CLEAN archive has none — `milestone complete` moves
// them all out — so this counts exactly the phases that outlived the archive,
// which is what distinguishes "archived" from "archived, then reopened".
// Deliberately NOT "…and unfinished": a complete live dir beside a declared
// but never-scaffolded phase is a dirty archive too, and the dirless phase has
// no directory to inspect.
let liveInMilestonePhases = 0;
for (const dir of [...phaseDirNames].sort()) {
const counts = countsMap.get(dir);
const planCount = counts?.planCount ?? 0;
const summaryCount = counts?.summaryCount ?? 0;
// ADR-3180 §7.4 (issue #3186): routed through the single canonical owner
// (`isPhaseComplete`, src/verification.cts) — via `PhaseFilesCount.complete`,
// which the I/O-capable CALLER computes (this module is a PURE, I/O-free
// projection — see the module header: "No I/O. No async." — and cannot
// call the owner itself). The prior local derivation
// (`summaryCount >= planCount && planCount > 0` combined with a
// caller-supplied verification status) was this module's OWN completion
// verdict computed from raw counts — the exact "post-process a canonical
// result locally" bypass §7.4 rules out, and it reproduced the disk-strict
// headline case (#3168): a zero-plan phase with a passing verification
// read `pending` instead of `complete`. `complete` defaults to `false`
// when absent so a caller that has not been updated to pass it never
// silently reads as complete.
const status: 'complete' | 'in_progress' | 'pending' =
(counts?.complete ?? false)
? 'complete'
: planCount > 0
? 'in_progress'
: 'pending';
// #2562: only current-milestone phases feed the rollup when scoping is on,
// and only one directory per phase key (see rollupDirs above).
const countsTowardMilestone = (!scoped || counts?.inMilestone !== false) && rollupDirs.has(dir);
if (countsTowardMilestone) {
totalPlans += planCount;
completedPlans += Math.min(summaryCount, planCount);
if (status === 'complete') completedPhases++;
liveInMilestonePhases++;
}
phases.push({
directory: dir,
status,
plan_count: planCount,
summary_count: summaryCount,
});
}
// #2562: the denominator is the current milestone's declared phase count when
// scoping is active (catches phases declared but never scaffolded), else the
// legacy whole-roadmap heading count.
const effectivePhaseCount = scoped ? currentMilestonePhaseCount : roadmapPhaseCount;
// #2562 invariant: the numerator counts de-duplicated in-milestone phase keys
// and the denominator counts the union of those keys with the roadmap's own
// declarations, so the numerator can never exceed it. Raising the numerator
// above the denominator means the two sides were derived in different key
// spaces — the defect class this issue is about. The old `Math.min(100, …)`
// capped that away and reported 100%; this makes it fail loudly instead.
if (scoped && completedPhases > effectivePhaseCount) {
throw new Error(
`workstream inventory invariant violated for "${name}": completed_phases (${completedPhases}) ` +
`exceeds the current-milestone denominator (${effectivePhaseCount}). The completion numerator and ` +
`denominator were derived in different phase-key spaces.`
);
}
// #1913: derive status from authoritative shipped signals rather than trusting
// the mutable STATE.md `Status` field. When a shipped signal is present, the
// workstream is "milestone complete" regardless of a stale field value.
//
// #2562 review: a shipped signal is a CLAIM, and a claim its own milestone's
// artifacts contradict must not be echoed as fact — the defect class this issue
// is about reaches `status`, not just `progress_percent`. The two signals need
// DIFFERENT cross-checks; one check for both regresses the commonest shape:
//
// - `heading` — operator-typed marker in the LIVE roadmap. Nothing has been
// archived, so every phase the milestone declares should be on disk and
// complete. Gate on the full ratio, which also catches the
// declared-but-never-scaffolded phases that have no directory to inspect.
// - `snapshot` — `milestones/<version>-ROADMAP.md`. The `milestone complete`
// run that writes it also MOVES the milestone's phase directories into
// `milestones/<version>-phases/` (milestone.cts:783-790) while COPYING —
// never truncating — the live ROADMAP (:700-702), so its Progress rows
// survive. A CLEAN archive therefore reads 0/N by construction, and gating
// it on the ratio alone would strip `milestone complete` from every
// archived milestone. But a live in-milestone directory means the archive
// is NOT clean — a phase was added or reopened after it, reachable because
// `milestone complete` does not advance STATE's `milestone:` field
// (state-transition.cts:83, :1335); only `/msd-new-milestone` does (:1224).
// Once any in-milestone directory is live the ratio IS meaningful again, so
// the check is the conjunction. Requiring the live directory to itself be
// unfinished was too narrow: it let a complete live dir alongside a
// declared-but-unscaffolded phase reproduce the reported symptom, since a
// dirless phase has nothing to inspect.
//
// The whole cross-check is scoped-only, and NOT because of the signal: when
// scoping is off, `effectivePhaseCount` is the whole-roadmap count and
// membership is everything, so there is no current-milestone artifact set to
// check a current-milestone claim against. `legacy` is additionally ungated by
// signal — it is the fallback for an unknown milestone version, which is
// exactly when scoping cannot engage either.
const fieldStatus = stateProjection.status;
const shippedContradicted = scoped && (
shippedSignal === 'heading'
? completedPhases < effectivePhaseCount
: shippedSignal === 'snapshot'
? liveInMilestonePhases > 0 && completedPhases < effectivePhaseCount
: false
);
const useDerived = milestoneShippedResolved && !shippedContradicted;
// Refusing the claim does not make the STATE field a safe fallback: it is
// operator-written and in this window it commonly ALSO reads "milestone
// complete", which would re-report the refused claim through the other door.
// Against contradicting artifacts, NEITHER source may assert completion.
const artifactOverride = shippedContradicted && isCompletedInventory(fieldStatus);
const status = useDerived
? 'milestone complete'
: artifactOverride
? 'in_progress'
: fieldStatus;
const status_source: 'field' | 'derived' = useDerived || artifactOverride ? 'derived' : 'field';
const status_conflict = (useDerived && !isCompletedInventory(fieldStatus)) || artifactOverride;
return {
name,
path: toPosixPath(path.relative(projectDir, workstreamDir)),
active: name === activeWorkstreamName,
files: {
roadmap: filesExist.roadmap,
state: filesExist.state,
requirements: filesExist.requirements,
},
status,
status_source,
status_conflict,
milestone_shipped_unverified: shippedContradicted,
current_phase: stateProjection.current_phase,
last_activity: stateProjection.last_activity,
phases,
phase_count: phases.length,
completed_phases: completedPhases,
roadmap_phase_count: effectivePhaseCount,
total_plans: totalPlans,
completed_plans: completedPlans,
// `clampPercent`'s 100 ceiling is unreachable under milestone scoping (the
// invariant above throws first) and matters only for the legacy unscoped
// path, where the denominator is a roadmap heading count that a caller
// cannot guarantee bounds the numerator.
//
// #3217 (ADR-3180 §7.6 rule 4) — WRITTEN REASON this site is NOT migrated
// onto the `SCOPE` enum this phase: `buildWorkstreamInventory` is a pure
// projection (no I/O — see the module header) fed `BuildWorkstreamInventoryInputs`
// by `workstream-inventory.cts`. Its own `milestoneScoped` is a pre-ADR-3180
// bespoke boolean, not a `SCOPE` value, and its caller does not currently
// thread a real `listMilestonePhaseDirs` scope into these inputs. Doing
// this honestly requires ONE of: (a) widening `BuildWorkstreamInventoryInputs`
// with a `Scope` field and `WorkstreamInventory.progress_percent`'s type
// from `number` to `number | null` — the exact "re-architecting
// StateProjection/WorkstreamInventory return types" the design phase
// (`.msd/phase/refactor-3217-completion-ratio-scoping/40-design.md`,
// "Known limits") states is OUT of this phase's scope; or (b) silently
// reusing `milestoneScoped` as a `Scope` stand-in, which would be exactly
// the kind of proxy-for-a-data-flow-property this same phase's guard
// section explicitly rejects (a `boolean` cannot distinguish TRUNCATED
// from UNSCOPED from UNREADABLE, so a caller could not tell which
// non-answer it got). Left un-migrated rather than done dishonestly;
// `workstream inventory`'s `progress_percent` can still render a number
// derived from an under-scoped set (A8 in the phase's test matrix is
// NOT covered here for that reason — see this phase's PR description).
progress_percent: clampPercent(completedPhases, effectivePhaseCount),
};
}