state.js was a 2012-line monolith combining shared helpers, DB-backed derivation, and legacy filesystem derivation. Split into four files: - state-shared.js (114 lines): helpers used by both DB and legacy paths isGhostMilestone, isSliceComplete, isMilestoneComplete, isValidationTerminal, readMilestoneValidationVerdict, loadTerminalSummary, stripMilestonePrefix, canonicalMilestonePrefix, extractContextTitle - state-db.js (841 lines): deriveStateFromDb() and its exclusive helpers reconcileDiskToDb, buildRegistryAndFindActive, handleNoActiveMilestone, handleAllSlicesDone, resolveSliceDependencies, reconcileSliceTasks, detectBlockers, checkReplanTrigger, checkInterruptedWork - state-legacy.js (895 lines): _deriveStateImpl() — filesystem-only path - state.js (228 lines): thin barrel — invalidateStateCache, getActiveMilestoneId, deriveState, re-exports from sub-modules All 1195 tests pass. No behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
114 lines
4.4 KiB
JavaScript
114 lines
4.4 KiB
JavaScript
// SF Extension — State Shared Helpers
|
|
// Helpers shared by both the DB-backed (state-db.js) and legacy filesystem
|
|
// (state-legacy.js) state derivation paths. No dependency on either.
|
|
|
|
import { existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { isTerminalMilestoneSummaryContent } from "./milestone-summary-classifier.js";
|
|
import { resolveMilestoneFile, sfRoot } from "./paths.js";
|
|
import {
|
|
getMilestone,
|
|
getMilestoneValidationAssessment,
|
|
isDbAvailable,
|
|
} from "./sf-db.js";
|
|
import { extractVerdict } from "./verdict-parser.js";
|
|
|
|
export function isGhostMilestone(basePath, mid) {
|
|
// If the milestone has a DB row, it's usually a known milestone — not a ghost.
|
|
// Exception: a "queued" row with no disk artifacts is a phantom from
|
|
// new_milestone_id that was never planned (#3645).
|
|
if (isDbAvailable()) {
|
|
const dbRow = getMilestone(mid);
|
|
if (dbRow) {
|
|
if (dbRow.status === "queued") {
|
|
const hasContent =
|
|
resolveMilestoneFile(basePath, mid, "CONTEXT") ||
|
|
resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT") ||
|
|
resolveMilestoneFile(basePath, mid, "ROADMAP") ||
|
|
resolveMilestoneFile(basePath, mid, "SUMMARY");
|
|
return !hasContent;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
// If a worktree exists for this milestone, it was legitimately created.
|
|
const root = sfRoot(basePath);
|
|
const wtPath = join(root, "worktrees", mid);
|
|
if (existsSync(wtPath)) return false;
|
|
// Fall back to content-file check: no substantive files means ghost.
|
|
const context = resolveMilestoneFile(basePath, mid, "CONTEXT");
|
|
const draft = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT");
|
|
const roadmap = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
|
const summary = resolveMilestoneFile(basePath, mid, "SUMMARY");
|
|
return !context && !draft && !roadmap && !summary;
|
|
}
|
|
// ─── Query Functions ───────────────────────────────────────────────────────
|
|
/**
|
|
* Check if all tasks in a slice plan are done.
|
|
*/
|
|
export function isSliceComplete(plan) {
|
|
return plan.tasks.length > 0 && plan.tasks.every((t) => t.done);
|
|
}
|
|
/**
|
|
* Check if all slices in a roadmap are done.
|
|
*/
|
|
export function isMilestoneComplete(roadmap) {
|
|
return roadmap.slices.length > 0 && roadmap.slices.every((s) => s.done);
|
|
}
|
|
/**
|
|
* Check whether a VALIDATION file's verdict is terminal.
|
|
* Any successfully extracted verdict (pass, needs-attention, needs-remediation,
|
|
* fail, etc.) means validation completed. Only return false when no verdict
|
|
* could be parsed — i.e. extractVerdict() returns undefined (#2769).
|
|
*/
|
|
export function isValidationTerminal(validationContent) {
|
|
return extractVerdict(validationContent) != null;
|
|
}
|
|
function getDbMilestoneValidationVerdict(milestoneId) {
|
|
if (!isDbAvailable()) return undefined;
|
|
const assessment = getMilestoneValidationAssessment(milestoneId);
|
|
const status = assessment?.status;
|
|
return typeof status === "string" && status.trim()
|
|
? status.trim().toLowerCase()
|
|
: undefined;
|
|
}
|
|
export async function readMilestoneValidationVerdict(basePath, milestoneId, load) {
|
|
const dbVerdict = getDbMilestoneValidationVerdict(milestoneId);
|
|
if (dbVerdict) {
|
|
return { terminal: true, verdict: dbVerdict };
|
|
}
|
|
if (isDbAvailable()) {
|
|
return { terminal: false, verdict: undefined, source: "db-missing" };
|
|
}
|
|
const validationFile = resolveMilestoneFile(
|
|
basePath,
|
|
milestoneId,
|
|
"VALIDATION",
|
|
);
|
|
const validationContent = validationFile ? await load(validationFile) : null;
|
|
return {
|
|
terminal: validationContent
|
|
? isValidationTerminal(validationContent)
|
|
: false,
|
|
verdict: validationContent ? extractVerdict(validationContent) : undefined,
|
|
};
|
|
}
|
|
export async function loadTerminalSummary(summaryFile, loadFn) {
|
|
if (!summaryFile) return null;
|
|
const sc = await loadFn(summaryFile);
|
|
if (sc == null || !isTerminalMilestoneSummaryContent(sc)) return null;
|
|
return sc;
|
|
}
|
|
export function stripMilestonePrefix(title) {
|
|
return title.replace(/^M\d+(?:-[a-z0-9]{6})?[^:]*:\s*/, "") || title;
|
|
}
|
|
export function canonicalMilestonePrefix(id) {
|
|
return id.match(/^([A-Z]\d{3})/)?.[1] ?? id;
|
|
}
|
|
export function extractContextTitle(content, fallback) {
|
|
if (!content) return fallback;
|
|
const h1 = content.split("\n").find((line) => line.startsWith("# "));
|
|
if (!h1) return fallback;
|
|
// Extract title from "# M005: Platform Foundation & Separation" format
|
|
return stripMilestonePrefix(h1.slice(2).trim()) || fallback;
|
|
}
|