* fix: use authoritative milestone status in web roadmap instead of slice heuristics (#2807) The roadmap view was deriving milestone status from slice completion flags, which disagrees with the actual GSD state model when milestones have lifecycle states (complete/active/pending/parked) or validation verdicts that differ from what slice progress implies. Add status and validationVerdict fields to WorkspaceMilestoneTarget, populate them from the state registry and VALIDATION files, and update getMilestoneStatus() to prefer the authoritative status with a fallback to the old heuristic for backward compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add .js import extension and slice type annotations in workspace-status Fixes TS2835 (missing .js extension for NodeNext resolution) and TS7006 (implicit any on slice callback parameters) that caused CI build failure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: extract workspace types to .ts file to avoid jsx resolution error Move WorkspaceTaskTarget, WorkspaceSliceTarget, WorkspaceMilestoneTarget, and RiskLevel to workspace-types.ts so that workspace-status.ts (a plain .ts file) can import them without requiring --jsx. The .tsx store file re-exports the types for backward compatibility. Fixes TS6142 in CI for PR #3258. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a725fa2d9d
commit
341a211be2
5 changed files with 199 additions and 34 deletions
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Bug #2807: Web roadmap derives milestone status from slice heuristics
|
||||
* instead of authoritative GSD milestone state.
|
||||
*
|
||||
* getMilestoneStatus() should prefer the authoritative `status` field on
|
||||
* WorkspaceMilestoneTarget (populated from the engine registry) rather
|
||||
* than inferring status from slice completion flags.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getMilestoneStatus } from "../../../../../web/lib/workspace-status.ts";
|
||||
|
||||
// Inline type to avoid importing .tsx (not compiled to .js by test pipeline)
|
||||
interface TestMilestone {
|
||||
id: string;
|
||||
title: string;
|
||||
roadmapPath?: string;
|
||||
status?: "complete" | "active" | "pending" | "parked";
|
||||
validationVerdict?: "pass" | "needs-attention" | "needs-remediation";
|
||||
slices: Array<{ id: string; title: string; done: boolean; tasks: Array<{ id: string; title: string; done: boolean }> }>;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeMilestone(overrides: Partial<TestMilestone> & { id: string }): TestMilestone {
|
||||
return {
|
||||
title: overrides.id,
|
||||
roadmapPath: undefined,
|
||||
slices: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test("getMilestoneStatus returns authoritative 'complete' even when slices are not all done", () => {
|
||||
const milestone = makeMilestone({
|
||||
id: "M001",
|
||||
status: "complete",
|
||||
slices: [
|
||||
{ id: "S01", title: "Slice 1", done: true, tasks: [] },
|
||||
{ id: "S02", title: "Slice 2", done: false, tasks: [] }, // not done
|
||||
],
|
||||
});
|
||||
// Before the fix, this would return "in-progress" because not all slices are done.
|
||||
// After the fix, it should return "done" because authoritative status is "complete".
|
||||
assert.equal(getMilestoneStatus(milestone, {}), "done");
|
||||
});
|
||||
|
||||
test("getMilestoneStatus returns authoritative 'active' regardless of slice state", () => {
|
||||
const milestone = makeMilestone({
|
||||
id: "M002",
|
||||
status: "active",
|
||||
slices: [
|
||||
{ id: "S01", title: "Slice 1", done: true, tasks: [] },
|
||||
{ id: "S02", title: "Slice 2", done: true, tasks: [] },
|
||||
],
|
||||
});
|
||||
// Before the fix, this would return "done" because all slices are done.
|
||||
// After the fix, it should return "in-progress" because authoritative status is "active".
|
||||
assert.equal(getMilestoneStatus(milestone, {}), "in-progress");
|
||||
});
|
||||
|
||||
test("getMilestoneStatus returns 'pending' for authoritative 'pending' even when some slices done", () => {
|
||||
const milestone = makeMilestone({
|
||||
id: "M003",
|
||||
status: "pending",
|
||||
slices: [
|
||||
{ id: "S01", title: "Slice 1", done: true, tasks: [] },
|
||||
{ id: "S02", title: "Slice 2", done: false, tasks: [] },
|
||||
],
|
||||
});
|
||||
// Before the fix, this would return "in-progress" because some slices are done.
|
||||
// After the fix, it should return "pending".
|
||||
assert.equal(getMilestoneStatus(milestone, {}), "pending");
|
||||
});
|
||||
|
||||
test("getMilestoneStatus maps 'parked' to 'pending' item status", () => {
|
||||
const milestone = makeMilestone({
|
||||
id: "M004",
|
||||
status: "parked",
|
||||
slices: [
|
||||
{ id: "S01", title: "Slice 1", done: true, tasks: [] },
|
||||
],
|
||||
});
|
||||
// Parked milestones should render as pending in the UI
|
||||
assert.equal(getMilestoneStatus(milestone, {}), "pending");
|
||||
});
|
||||
|
||||
test("getMilestoneStatus falls back to heuristic when no authoritative status", () => {
|
||||
// Backward compatibility: milestones without the status field should
|
||||
// still work using the old slice-based heuristic.
|
||||
const milestone = makeMilestone({
|
||||
id: "M005",
|
||||
slices: [
|
||||
{ id: "S01", title: "Slice 1", done: true, tasks: [] },
|
||||
{ id: "S02", title: "Slice 2", done: true, tasks: [] },
|
||||
],
|
||||
});
|
||||
assert.equal(getMilestoneStatus(milestone, {}), "done");
|
||||
});
|
||||
|
||||
test("getMilestoneStatus exposes validationVerdict on milestone target", () => {
|
||||
const milestone = makeMilestone({
|
||||
id: "M006",
|
||||
status: "complete",
|
||||
validationVerdict: "needs-attention",
|
||||
slices: [
|
||||
{ id: "S01", title: "Slice 1", done: true, tasks: [] },
|
||||
],
|
||||
});
|
||||
// The milestone should have the validationVerdict field available
|
||||
assert.equal(milestone.validationVerdict, "needs-attention");
|
||||
// And status should still be "done"
|
||||
assert.equal(getMilestoneStatus(milestone, {}), "done");
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
resolveTasksDir,
|
||||
} from "./paths.js";
|
||||
import { deriveState } from "./state.js";
|
||||
import { extractVerdict } from "./verdict-parser.js";
|
||||
import { milestoneIdSort, findMilestoneIds } from "./guided-flow.js";
|
||||
import type { RiskLevel } from "./types.js";
|
||||
import { getSliceBranchName, detectWorktreeName } from "./worktree.js";
|
||||
|
|
@ -42,6 +43,10 @@ export interface WorkspaceMilestoneTarget {
|
|||
id: string;
|
||||
title: string;
|
||||
roadmapPath?: string;
|
||||
/** Authoritative milestone lifecycle status from the GSD state registry. */
|
||||
status?: "complete" | "active" | "pending" | "parked";
|
||||
/** Milestone validation verdict, when validation has been performed. */
|
||||
validationVerdict?: "pass" | "needs-attention" | "needs-remediation";
|
||||
slices: WorkspaceSliceTarget[];
|
||||
}
|
||||
|
||||
|
|
@ -192,6 +197,31 @@ export async function indexWorkspace(basePath: string, opts: IndexWorkspaceOptio
|
|||
phase: state.phase,
|
||||
};
|
||||
|
||||
// Enrich milestones with authoritative status from state registry (#2807)
|
||||
if (state.registry) {
|
||||
const registryMap = new Map(state.registry.map(e => [e.id, e]));
|
||||
for (const milestone of milestones) {
|
||||
const entry = registryMap.get(milestone.id);
|
||||
if (entry) {
|
||||
milestone.status = entry.status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Populate validationVerdict from VALIDATION files (#2807)
|
||||
for (const milestone of milestones) {
|
||||
const validationPath = resolveMilestoneFile(basePath, milestone.id, "VALIDATION");
|
||||
if (validationPath) {
|
||||
const validationContent = await loadFile(validationPath);
|
||||
if (validationContent) {
|
||||
const verdict = extractVerdict(validationContent);
|
||||
if (verdict === "pass" || verdict === "needs-attention" || verdict === "needs-remediation") {
|
||||
milestone.validationVerdict = verdict;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scopes: WorkspaceScopeTarget[] = [{ scope: "project", label: "project", kind: "project" }];
|
||||
for (const milestone of milestones) {
|
||||
scopes.push({ scope: milestone.id, label: `${milestone.id}: ${milestone.title}`, kind: "milestone" });
|
||||
|
|
|
|||
|
|
@ -125,37 +125,7 @@ export interface BridgeRuntimeSnapshot {
|
|||
lastError: BridgeLastError | null
|
||||
}
|
||||
|
||||
export interface WorkspaceTaskTarget {
|
||||
id: string
|
||||
title: string
|
||||
done: boolean
|
||||
planPath?: string
|
||||
summaryPath?: string
|
||||
}
|
||||
|
||||
export type RiskLevel = "low" | "medium" | "high"
|
||||
|
||||
export interface WorkspaceSliceTarget {
|
||||
id: string
|
||||
title: string
|
||||
done: boolean
|
||||
planPath?: string
|
||||
summaryPath?: string
|
||||
uatPath?: string
|
||||
tasksDir?: string
|
||||
branch?: string
|
||||
risk?: RiskLevel
|
||||
depends?: string[]
|
||||
demo?: string
|
||||
tasks: WorkspaceTaskTarget[]
|
||||
}
|
||||
|
||||
export interface WorkspaceMilestoneTarget {
|
||||
id: string
|
||||
title: string
|
||||
roadmapPath?: string
|
||||
slices: WorkspaceSliceTarget[]
|
||||
}
|
||||
export type { WorkspaceTaskTarget, RiskLevel, WorkspaceSliceTarget, WorkspaceMilestoneTarget } from "./workspace-types.js"
|
||||
|
||||
export interface WorkspaceScopeTarget {
|
||||
scope: string
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type {
|
|||
WorkspaceMilestoneTarget,
|
||||
WorkspaceSliceTarget,
|
||||
WorkspaceTaskTarget,
|
||||
} from "./gsd-workspace-store"
|
||||
} from "./workspace-types.js"
|
||||
|
||||
export type ItemStatus = "done" | "in-progress" | "pending"
|
||||
|
||||
|
|
@ -10,13 +10,27 @@ export function getMilestoneStatus(
|
|||
milestone: WorkspaceMilestoneTarget,
|
||||
active: { milestoneId?: string },
|
||||
): ItemStatus {
|
||||
if (milestone.slices.length > 0 && milestone.slices.every((slice) => slice.done)) {
|
||||
// Prefer authoritative milestone status from GSD state registry (#2807)
|
||||
if (milestone.status) {
|
||||
switch (milestone.status) {
|
||||
case "complete":
|
||||
return "done"
|
||||
case "active":
|
||||
return "in-progress"
|
||||
case "pending":
|
||||
case "parked":
|
||||
return "pending"
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: infer from slice completion (legacy / no status field)
|
||||
if (milestone.slices.length > 0 && milestone.slices.every((slice: WorkspaceSliceTarget) => slice.done)) {
|
||||
return "done"
|
||||
}
|
||||
if (active.milestoneId === milestone.id) {
|
||||
return "in-progress"
|
||||
}
|
||||
return milestone.slices.some((slice) => slice.done) ? "in-progress" : "pending"
|
||||
return milestone.slices.some((slice: WorkspaceSliceTarget) => slice.done) ? "in-progress" : "pending"
|
||||
}
|
||||
|
||||
export function getSliceStatus(
|
||||
|
|
|
|||
35
web/lib/workspace-types.ts
Normal file
35
web/lib/workspace-types.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
export interface WorkspaceTaskTarget {
|
||||
id: string
|
||||
title: string
|
||||
done: boolean
|
||||
planPath?: string
|
||||
summaryPath?: string
|
||||
}
|
||||
|
||||
export type RiskLevel = "low" | "medium" | "high"
|
||||
|
||||
export interface WorkspaceSliceTarget {
|
||||
id: string
|
||||
title: string
|
||||
done: boolean
|
||||
planPath?: string
|
||||
summaryPath?: string
|
||||
uatPath?: string
|
||||
tasksDir?: string
|
||||
branch?: string
|
||||
risk?: RiskLevel
|
||||
depends?: string[]
|
||||
demo?: string
|
||||
tasks: WorkspaceTaskTarget[]
|
||||
}
|
||||
|
||||
export interface WorkspaceMilestoneTarget {
|
||||
id: string
|
||||
title: string
|
||||
roadmapPath?: string
|
||||
/** Authoritative milestone lifecycle status from the GSD state registry. */
|
||||
status?: "complete" | "active" | "pending" | "parked"
|
||||
/** Milestone validation verdict, when validation has been performed. */
|
||||
validationVerdict?: "pass" | "needs-attention" | "needs-remediation"
|
||||
slices: WorkspaceSliceTarget[]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue