* test(#2645): pin failing-first regression coverage for verification-deletion ledger Deleting a *-VERIFICATION.md file after a failing gaps_found/human_needed verdict was recorded silently raises reported workstream completion — the deletion is indistinguishable from "verifier never ran" at the verdict-lookup layer. These tests pin the correct behavior (deletion must never raise completed_phases/progress_percent) and fail against the current code, which has no such protection. * fix(#2645): persist verification verdicts across deletion in workstream rollup FAILING_VERIFICATION_STATUSES gated phase completeness on a verdict read fresh from *-VERIFICATION.md on every call. Deleting that file made "verifier found gaps, report later deleted" indistinguishable from "verifier never ran" (both read the internal 'missing' sentinel), so removing evidence silently raised completed_phases/progress_percent. Persist the last real verdict observed per phase key in a ledger file at the workstream directory level (outside every phase directory, so the same deletion that triggers the hole cannot also erase the memory of it). The ledger is consulted only when a live read comes back 'missing' and is updated whenever a real verdict is observed, so a genuinely re-verified phase is never permanently pinned, and verifier-disabled or not-yet-verified phases are unaffected (no ledger entry is ever created for them). Ledger-winner selection walks phase directories in the same sorted order buildWorkstreamInventory's own duplicate-directory tie-break uses, so a stale same-numbered directory can never clobber the live directory's remembered verdict on an exact mtime tie. Adds fault-injection coverage for the ledger read/write per CONTRIBUTING.md's filesystem-writes rules. * fix(#2645): redesign verification ledger to fail closed, not open The two-state ledger read (any read/parse failure -> "nothing remembered") failed OPEN: deleting the ledger alongside the report, or simply corrupting it while the report was already gone, degraded to the same 'missing' sentinel this issue exists to stop trusting -- silently reopening the completion-inflation hole one level up. Redesigned as a three-state read distinguishing 'absent' (no ledger file at all -- ENOENT specifically, disambiguated from a broken symlink via lstatSync) from 'corrupt' (file exists but unreadable/unparseable/ wrong shape) from 'ok'. Only 'absent' behaves as pre-fix (ungated) -- deliberate, since every existing project is in that state for every workstream on the day this ships. 'corrupt' and 'ok' both fail closed for a phase with no trustworthy entry, via a new internal sentinel 'unrecorded' added to FAILING_VERIFICATION_STATUSES. A corrupt ledger is not a permanent wedge: it is only overwritten when a real verdict is actually observed (never patched with an empty object), so re-verifying even one phase repairs the file. The ledger write is now atomic (temp file + rename, mirroring broken-windows.cts's writeLedgerAtomic/renameWithRetry shape) so this fix does not itself produce the corrupt files it now treats as security-relevant. Disclosed, accepted residual gap, pinned as an explicit test: deleting the ledger file itself (not just the report) still returns a workstream to the pre-adoption 'absent' state. This is inherent to any design where a wholly-absent store must be safe by default -- the alternative is gating every never-verified phase in every project on upgrade. Rail B is prospective only; a phase deleted before this fix shipped cannot be retroactively recovered. Also: dropped 'stale' from the Row 9 property test's REAL_STATUSES (it does not round-trip through readVerificationStatus as written, so including it claimed coverage the test did not have), converted two try/finally test bodies to t.after(), and added the remaining CONTRIBUTING.md fault-injection cases (broken symlink, missing parent directory, rename failure, temp-file cleanup). * fix(#2645): share the rollup winner selection to close a scoping gap BLOCKER: the ledger-winner selection in workstream-inventory.cts compared raw mtimes with no milestone-scoping filter, while the builder's own rollupDirByKey filters out-of-milestone directories before comparing. In a scoped workstream, a stale out-of-milestone duplicate-key directory with a newer mtime could win the ledger's selection while losing the builder's -- so deleting the LIVE directory's report never consulted the ledger, reopening #2645's hole for the phase that actually counts toward completed_phases, reachable with a plain rm. Extracted pickRollupWinners as the single shared implementation both rollupDirByKey and the ledger's winner selection now call, with the identical scoping filter -- two independent hand-written copies of "pick the winner" is what produced the divergence; one implementation makes the bug class structurally impossible rather than merely tested against. Added a unit-level proof (synthetic same-key entries with opposing inclusion/mtime) and an integration-level proof (a scoped workstream asserting an out-of-milestone verdict is never written into the ledger). Also: disclosed a third residual limitation in the changeset (editing a ledger entry by hand plants a permanent false verdict -- worse than deleting the ledger, since it looks like genuine history; not made tamper-proof, that's scope creep here); fixed a non-ENOENT lstatSync failure falling open to 'absent' instead of failing closed like every other path in that function; and fixed two test bugs a real gsd-test run caught -- Row 11's write-failure mock matched only the final ledger path, but the atomic-write refactor moved the real write target to a temp file, so the mock silently stopped intercepting anything and the test's own assertion caught its own staleness. * test(#2645): relabel Row 19 honestly and pin the lstat fail-closed fix Row 19 used two DIFFERENT phase keys (1-old, 2-new), so it never exercised the same-key collision the milestone-scoping blocker fix addresses -- the pre-fix, unshared ledger-winner code would have satisfied it too. Its docstring called it the integration-level proof of the blocker; it is not. Relabeled both rows accurately: Row 18 (synthetic same-key data) is now stated as the only row that proves the collision end to end, and Row 19 is described for what it genuinely covers -- a distinctly-keyed out-of-milestone phase's verdict never reaching the ledger, real coverage but not the collision case. Extensive probing (documented in 10-diagnosis.md) could not construct a natural directory-naming pair that shares a rollup key while diverging in milestone membership under the current roadmap-parser implementation, so the collision proof stays unit-level by necessity, not convenience. Also added a test pinning the lstat fail-closed fix: readVerificationLedger disambiguates a broken symlink (ENOENT from readFileSync) from genuine absence via a follow-up lstatSync call, and only lstatSync itself reporting ENOENT is proof of absence. A double-fault (readFileSync ENOENT, lstatSync a DIFFERENT code) cannot occur on a real filesystem, so it's monkeypatched directly -- without a test, a future edit could re-widen that catch back to "any lstat failure means absent" and fall open again silently. * chore(#2645): backfill changeset PR number to 3016 --------- Co-authored-by: sim <sim@local>
438 lines
20 KiB
TypeScript
438 lines
20 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';
|
|
|
|
// Internal helpers
|
|
function toPosixPath(p: string): string {
|
|
return p.split('\\').join('/');
|
|
}
|
|
|
|
/**
|
|
* #2562: verification verdicts that DISQUALIFY a phase from `complete`, even
|
|
* when its SUMMARY count meets its PLAN count. Deliberately scoped to the two
|
|
* EXPLICIT failing verdicts the verifier emits — `missing`/`unknown` (verifier
|
|
* off / not yet run) and `stale` (mtime-derived, #2348) are intentionally left
|
|
* untouched so verifier-disabled projects do not regress to never-complete.
|
|
*
|
|
* #2645: `'unrecorded'` is NOT a verdict the verifier itself ever emits — it
|
|
* is an internal sentinel `workstream-inventory.cts`'s verification-deletion
|
|
* ledger substitutes for `'missing'` once that workstream has ADOPTED the
|
|
* ledger (a `.verification-ledger.json` file exists for it) but has no
|
|
* remembered entry for this specific phase. Pre-adoption (no ledger file at
|
|
* all) still resolves to plain `'missing'`, which stays OUTSIDE this set —
|
|
* that is what keeps a project untouched by this fix until it actually uses
|
|
* the verifier at least once. Post-adoption, an unrecorded phase fails
|
|
* CLOSED (counted here) rather than open, so a corrupt or evidence-absent
|
|
* ledger entry can no longer be read as "safe to complete" the way a bare
|
|
* `'missing'` sentinel is.
|
|
*/
|
|
const FAILING_VERIFICATION_STATUSES = new Set<string>(['gaps_found', 'human_needed', 'unrecorded']);
|
|
|
|
/**
|
|
* #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`).
|
|
* A phase with SUMMARY count ≥ PLAN count but a failing verdict
|
|
* (`gaps_found`/`human_needed`) must NOT count as complete.
|
|
*/
|
|
verificationStatus?: string;
|
|
}
|
|
|
|
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;
|
|
// #2562: SUMMARY≥PLAN parity is necessary but not sufficient — a phase whose
|
|
// verification verdict is an explicit failing one is still in progress.
|
|
const verificationStatus = counts?.verificationStatus ?? 'missing';
|
|
const summariesMeetPlans = summaryCount >= planCount && planCount > 0;
|
|
const status: 'complete' | 'in_progress' | 'pending' =
|
|
summariesMeetPlans && !FAILING_VERIFICATION_STATUSES.has(verificationStatus)
|
|
? '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 `/gsd-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,
|
|
// The `Math.min` cap is unreachable under milestone scoping (the invariant
|
|
// above throws first) and survives only for the legacy unscoped path, where
|
|
// the denominator is a roadmap heading count that a caller cannot guarantee
|
|
// bounds the numerator.
|
|
progress_percent:
|
|
effectivePhaseCount > 0
|
|
? Math.min(100, Math.round((completedPhases / effectivePhaseCount) * 100))
|
|
: 0,
|
|
};
|
|
}
|