* feat(#2790): add read-only planning.inspect schema-v1 snapshot query Adds a read-only query emitting a schema-versioned JSON projection of .planning/ so downstream harness UIs can consume planning state without parsing GSD's Markdown a second time. Composed strictly from the ADR-3180 section 7 owners plus parsePlanDocument, parseRequirements and parseUatItems; markdown structure is read through the Markdown Sectionizer and Markdown Table Model seams. It declares its own flat external schema rather than serializing PlanningSnapshot, which is the diagnostic-rule subject and still growing. Extracts plan-document parsing out of cmdPhasePlanIndex into a shared leaf module so phase.plan-index and planning.inspect cannot drift, including the plan-id derivation both surfaces report. Also fixes parseRequirements dropping the separator delimiter used by the shipped requirements template, surfaced while wiring the requirement rows. * fix(#2790): close spec gaps and a raw-text test assertion found in review Review findings from the standards, spec and security passes: - phases[] rows carry goal and dependencies, the two per-phase elements the issue Summary names that had no corresponding field. Goal is bounded to the section's leading prose so the Depends-on line, the Plans checklist and the wave annotations are not duplicated into it. - requirement rows carry their own diagnostic codes, so a consumer no longer has to string-parse the global diagnostics subject to correlate. - roadmap_acceptance.checkbox is looked up through the phase-id key owners. It was compared raw against the on-disk directory name, so it read null for every real-world slugged phase directory and the evidence channel was inert. - the hostile-input test asserts the structured payload instead of matching the raw stdout string. The absence proof over raw stdout is kept deliberately. * fix(#2790): register planning in the runtime usage list and repair fixtures Remote runner reported 9 failures on 9b3f9aa. Two root causes, both fixed: - gsd-tools.cjs registered the planning family in HOST_COMMAND_ROUTERS but never added it to TOP_LEVEL_USAGE's Commands list. Those are two surfaces a parity test guards, and the top-of-file block comment is not the runtime help string. A real wiring gap that every local gate and three review passes missed. - the new suite's fixtures could not produce a resolvable phase set. STATE.md frontmatter omitted the milestone field, which ADR-3180 7.2 rule 1 makes the primary milestone selector, so the phase set scoped unscoped and every percentage was correctly withheld. Separately declarePhase returned a path without creating the directory, so a phase declared but never written to left phases empty. Both reproduced against the built module before fixing. No assertion was weakened. The withholding path is still exercised and still returns null when the roadmap is absent. * chore(#2790): backfill changeset pr number * test(#2790): cover every enumerated matrix row and contain a symlink escape Reverses a silent deferral. An earlier revision left 23 of the 78 enumerated matrix rows unimplemented and 7 more as one-off manual checks, with a paragraph in the artifact and the PR body describing the gap. CLAUDE.md is explicit that such a note is not a fix and is not surfacing. The rows are implemented instead and the manual-evidence bucket is gone: 49 test cases become 88, covering all 78. Writing the symlink row proved a real leak: a *-PLAN.md symlinked outside .planning/ had its content emitted into the payload, confirmed via a direct call and the spawned CLI. readDocument now resolves target and planning root with realpathSync and rejects an escape, returning the ordinary unreadable-document shape. Tested both ways, because a containment check that over-rejects is its own defect: an escaping symlink leaks nothing and degrades that plan alone, while a legitimately relocated .planning/ symlink stays fully readable. The three new modules are registered in the mutation COVERED registry, which had been reporting has_work false and skipping the Stryker gate entirely. Provisional non-binding floors so the shards run and report; raised to the measured value before merge, since the registry forbids calibrating from a local run. * fix(#2790): satisfy the mutation ratchet contract and scope the 1MB test Remote runner reported 16 failures on 8c451ed. Two causes. The COVERED registry has a paired contract the earlier commit violated: every module needs a matching RATCHET_BASELINE entry, and minScore must be between 50 and 100 with minScore === baseline. The provisional floor of 1 was illegal on both counts. All three modules now sit at 50 — the registry's own enforced minimum — with matching baselines. The score cannot be measured locally: the shard runs node --test, which this repo hard-blocks, so CI is the only source. Floors are raised to the measured value once this PR's shards report; a shard below 50 means the tests need strengthening, since the floor cannot go lower. The 1MB test was measuring the test harness rather than the product. The command handles the oversized payload correctly by spilling to a tmpfile and resolving it back, but the resolved stdout then exceeds runGsdTools' maxBuffer and the helper reports ENOBUFS. It now uses --pick so stdout stays one byte while the full 1MB document is still read and parsed end to end. * fix(#2790): wire containment across every document read this command drives An isolated security review of the containment control found the boundary logic sound but not comprehensively wired: two content reads reached the filesystem without it. An escaped phase DIRECTORY could enumerate external filenames into the file fields and diagnostic subjects. Both enumeration sites now containment-check the directory before reading. Worth recording that the leak was already prevented one layer earlier than the review claimed: Dirent#isDirectory() reports false for a directory symlink, so such a directory never becomes a phase row at all. The guard is defense-in-depth for a direct caller and for platforms where a reparse point reports as a directory. A *-VERIFICATION.md symlinked outside the root leaked one frontmatter value verbatim, because readVerificationStatus does its own read and copies an unrecognized status into the payload's next_action. Closed from the consumer side through that function's existing fs injection seam, so src/verification.cts keeps its signature and its other callers are untouched. The reviewer additionally rated a forged status: passed as an integrity bypass. It is not: anyone able to plant the symlink can plant a real VERIFICATION.md saying the same thing. The incremental risk is confidentiality, which is what these fixes close. src/plan-scan.cts is deliberately unchanged: isPlanSuperseded reads symlink-followed content but yields only a derived boolean, no document text. * test(#2790): give the mutation shards an in-process surface Two Stryker shards were CANCELLED at the 15-minute cap, not failed on score. CI log: 640 mutants instrumented, and the dry run reported 'Ran 1 tests in 20 seconds' because the shards pointed at the integration suite, where nearly every case spawns a gsd-tools subprocess and Stryker's command runner treats the whole test-runner invocation as a single test. 640 x 20s cannot finish in 15 minutes; at the kill it was 27/640 with an ETA over an hour. Every other COVERED module points at a property or unit file, and the workflow's own paths filter lists exactly those two patterns. In-process is the intended mutation surface; the shards were pointed at the wrong shape of test. Adds tests/planning-inspect.unit.test.cjs — 39 cases in 10 describes that spawn nothing and call the built modules directly. plan-document and the router need no filesystem at all, one being a pure content-to-object parser and the other taking an injected mock. The three shards now point here. The 91-case integration suite is untouched and still runs in the normal test job. * chore(#2790): ratchet mutation floors to the measured CI scores CI run 32392791843 measured all three shards, which is the only source the registry accepts — local runs count timeouts as kills and inflate badly. planning-command-router 95.65 -> floor 94 plan-document 76.58 -> floor 75 planning-inspect 57.03 -> floor 56 Applied the registry's own rule, floor(score) - 1, and updated RATCHET_BASELINE to match, since the ratchet test enforces equality. planning-inspect sits well below the file's target of 80 and is the obvious ratchet candidate as its tests improve. planning-command-router already exceeds the target. The placeholder comment about floors pending measurement is removed rather than left standing as a false statement. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -92,6 +92,17 @@ function parseRequirements(reqMd: unknown): ReqItem[] {
|
||||
// The **ID** is extracted from the bullet text caller-side — the seam provides
|
||||
// the raw text; we parse the bold-ID prefix from it here.
|
||||
const boldIdRe = new RegExp(`^\\*\\*(${ID_PATTERN})\\*\\*\\s*(.*)$`);
|
||||
// The shipped template (gsd-core/templates/requirements.md) writes
|
||||
// `- [ ] **AUTH-01**: User can sign up` — a single separator delimiter
|
||||
// between the bold ID and the description. Strip AT MOST ONE leading
|
||||
// delimiter (plus its surrounding whitespace) before the final `.trim()`,
|
||||
// mirroring roadmap-parser.cts's `stripLeadingDelimiter` delimiter set
|
||||
// (em dash, en dash, colon, hyphen) — that helper is not exported, and its
|
||||
// own `+`-quantified strip removes an entire delimiter RUN, which would
|
||||
// also eat a second, meaningful marker (`**X-01**: -- weird` must keep the
|
||||
// `--`), so the set is mirrored here with a single-occurrence match instead
|
||||
// of reused verbatim.
|
||||
const ONE_LEADING_DELIMITER_RE = /^\s*[—–:-]\s*/;
|
||||
for (const bullet of iterateBullets(reqMd)) {
|
||||
if (bullet.marker !== 'checkbox-unchecked' && bullet.marker !== 'checkbox-checked') continue;
|
||||
const m = boldIdRe.exec(bullet.text);
|
||||
@@ -100,7 +111,8 @@ function parseRequirements(reqMd: unknown): ReqItem[] {
|
||||
if (!idRe.test(id)) continue;
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
out.push({ id, text: (m[2] || '').trim() });
|
||||
const rawText = m[2] || '';
|
||||
out.push({ id, text: rawText.replace(ONE_LEADING_DELIMITER_RE, '').trim() });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,9 @@ const {
|
||||
} = planningWorkspace;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- milestone-lock.cjs is an export= CommonJS module
|
||||
import milestoneLockMod = require('./milestone-lock.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planDocumentMod = require('./plan-document.cjs');
|
||||
const { parsePlanDocument, planIdFromFile } = planDocumentMod;
|
||||
const { extractFrontmatter } = frontmatterMod;
|
||||
const {
|
||||
readModifyWriteStateMd,
|
||||
@@ -583,11 +586,6 @@ function cmdFindPhase(cwd: string, phase: string, raw: boolean): void {
|
||||
output(notFound, raw, '');
|
||||
}
|
||||
|
||||
function extractObjective(content: string): string | null {
|
||||
const m = content.match(/<objective>\s*\n?\s*(.+)/);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
interface RawPlan {
|
||||
id: string;
|
||||
declaredWave: number | null;
|
||||
@@ -800,51 +798,14 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
|
||||
const rawPlans: RawPlan[] = [];
|
||||
|
||||
for (const planFile of planFiles) {
|
||||
const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', '');
|
||||
const planId = planIdFromFile(planFile);
|
||||
const planPath = path.join(phaseDir, planFile);
|
||||
const content = fs.readFileSync(planPath, 'utf-8');
|
||||
// Pass planPath so a truncated PLAN.md names the file in the #1882 diagnostic.
|
||||
const fm = extractFrontmatter(content, planPath);
|
||||
|
||||
const xmlTasks = content.match(/<task[\s>]/gi) || [];
|
||||
const mdTasks = content.match(/##\s*Task\s*\d+/gi) || [];
|
||||
const taskCount = xmlTasks.length || mdTasks.length;
|
||||
|
||||
const parsedWave = parseInt(fm['wave'] as string, 10);
|
||||
const declaredWave = Number.isNaN(parsedWave) ? null : parsedWave;
|
||||
|
||||
let dependsOn: string[] = [];
|
||||
const fmDeps = fm['depends_on'];
|
||||
if (Array.isArray(fmDeps)) {
|
||||
dependsOn = fmDeps.map(String);
|
||||
} else if (typeof fmDeps === 'string' && fmDeps.trim() !== '') {
|
||||
dependsOn = [fmDeps];
|
||||
}
|
||||
|
||||
let autonomous = true;
|
||||
if (fm['autonomous'] !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue comparison
|
||||
autonomous = fm['autonomous'] === 'true' || String(fm['autonomous']) === 'true';
|
||||
}
|
||||
|
||||
let filesModified: string[] = [];
|
||||
const fmFiles = fm['files_modified'] || fm['files-modified'];
|
||||
if (fmFiles) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
|
||||
filesModified = Array.isArray(fmFiles) ? fmFiles.map(String) : [String(fmFiles)];
|
||||
}
|
||||
|
||||
// #1689: optional per-plan specialist executor hint. Read verbatim here; the
|
||||
// orchestrator resolves it against the active runtime's agent dir at dispatch
|
||||
// time (execute-phase.md -> `gsd_run query resolve-agent`), falling back to
|
||||
// gsd-executor when the field is unset or the named agent does not resolve.
|
||||
let agentHint: string | null = null;
|
||||
const fmAgentHint = fm['agent_hint'];
|
||||
if (fmAgentHint !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
|
||||
const hintStr = String(fmAgentHint).trim();
|
||||
agentHint = hintStr !== '' ? hintStr : null;
|
||||
}
|
||||
// #2790: plan-body parsing is owned by the shared Plan Document Module, so
|
||||
// this command and the read-only `planning.inspect` query cannot drift on
|
||||
// what a plan document says. planPath is still passed so a truncated
|
||||
// PLAN.md names the file in the #1882 diagnostic.
|
||||
const planDoc = parsePlanDocument(content, planPath);
|
||||
|
||||
const hasSummary = !unsummarizedPlanFiles.has(planFile);
|
||||
|
||||
@@ -860,13 +821,13 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
|
||||
|
||||
rawPlans.push({
|
||||
id: planId,
|
||||
declaredWave,
|
||||
dependsOn,
|
||||
autonomous,
|
||||
objective: extractObjective(content) || (fm['objective'] as string | null) || null,
|
||||
filesModified,
|
||||
agentHint,
|
||||
taskCount,
|
||||
declaredWave: planDoc.declaredWave,
|
||||
dependsOn: planDoc.dependsOn,
|
||||
autonomous: planDoc.autonomous,
|
||||
objective: planDoc.objective,
|
||||
filesModified: planDoc.filesModified,
|
||||
agentHint: planDoc.agentHint,
|
||||
taskCount: planDoc.taskCount,
|
||||
hasSummary,
|
||||
halted,
|
||||
});
|
||||
|
||||
322
src/plan-document.cts
Normal file
322
src/plan-document.cts
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Plan Document Module — the single parser for a `*-PLAN.md` document BODY.
|
||||
*
|
||||
* Owns: objective extraction, the task-block grammar (`<task>` elements, with
|
||||
* the legacy `## Task N` heading fallback), planned-file extraction, and the
|
||||
* frontmatter-derived scheduling metadata (`wave`, `depends_on`, `autonomous`,
|
||||
* `agent_hint`, `files_modified`).
|
||||
*
|
||||
* WHY THIS IS A LEAF MODULE. This logic was written inline inside
|
||||
* `cmdPhasePlanIndex` (`src/phase.cts`). Two commands in two different families
|
||||
* now need it — `phase.plan-index` and `planning.inspect` (#2790) — so leaving
|
||||
* it in `phase.cts` would force `planning` to depend on `phase`, and copying it
|
||||
* would be the *Generative Fix Divergence* class `CLAUDE.md` names. A leaf owned
|
||||
* by neither family is the seam that matches the actual usage (Conway's Law).
|
||||
*
|
||||
* NOT an ADR-3180 §7 derivation. §6 puts the document-parsing layer explicitly
|
||||
* out of that epic's scope (#2143); this module answers "what does this plan
|
||||
* document say", never "how many plans are outstanding" (that is
|
||||
* `scanPhasePlans`, §7.5) or "is this phase complete" (`isPhaseComplete`, §7.4).
|
||||
*
|
||||
* BEHAVIOUR IS PRESERVED BYTE-FOR-BEHAVIOUR from the prior inline code. In
|
||||
* particular `tasks.length` is exactly the legacy `taskCount`
|
||||
* (`xmlTasks.length || mdTasks.length`), including its known fence-blindness —
|
||||
* a `## Task 1` inside a fenced code block still counts, exactly as it does
|
||||
* today. That is a characterised limit, not an endorsement: changing it would
|
||||
* silently change `phase.plan-index`'s output for existing projects, which is a
|
||||
* Hyrum's-Law break that belongs in its own issue rather than riding along on a
|
||||
* read-only query addition.
|
||||
*
|
||||
* ADR-457 build-at-publish: source in src/plan-document.cts, compiled to
|
||||
* gsd-core/bin/lib/plan-document.cjs (gitignored).
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import frontmatterMod = require('./frontmatter.cjs');
|
||||
const { extractFrontmatter } = frontmatterMod;
|
||||
|
||||
// ─── Frozen vocabularies ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* How a task row was expressed in the document. `auto` is the ordinary
|
||||
* executable task; `checkpoint` is a `<task type="checkpoint:*">` block, which
|
||||
* carries an entirely different element set (`<decision>`/`<what-built>`, no
|
||||
* `<name>`/`<files>`/`<acceptance_criteria>`). Distinguishing them is what
|
||||
* stops a checkpoint from being reported as a malformed auto task.
|
||||
*/
|
||||
const TASK_KIND = Object.freeze({
|
||||
AUTO: 'auto',
|
||||
CHECKPOINT: 'checkpoint',
|
||||
});
|
||||
|
||||
type TaskKind = (typeof TASK_KIND)[keyof typeof TASK_KIND];
|
||||
|
||||
// ─── Shapes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PlanTask {
|
||||
/** 1-based position in document order. */
|
||||
index: number;
|
||||
kind: TaskKind;
|
||||
/** The verbatim `type` attribute when present (e.g. `auto`, `checkpoint:decision`). */
|
||||
type: string | null;
|
||||
/** `<name>` text, or null — always null for a checkpoint block. */
|
||||
name: string | null;
|
||||
/** `<files>` split on commas, trimmed, empties dropped. Never null; `[]` means "none declared". */
|
||||
plannedFiles: string[];
|
||||
/** `<acceptance_criteria>` bullet lines, in document order. */
|
||||
acceptanceCriteria: string[];
|
||||
/** `<done>` text, or null. */
|
||||
done: string | null;
|
||||
}
|
||||
|
||||
interface PlanDocument {
|
||||
/** `<objective>` body, else frontmatter `objective`, else null. */
|
||||
objective: string | null;
|
||||
/** Frontmatter `wave` as an integer, or null when absent/unparseable. */
|
||||
declaredWave: number | null;
|
||||
dependsOn: string[];
|
||||
autonomous: boolean;
|
||||
agentHint: string | null;
|
||||
/** Frontmatter `files_modified` / `files-modified`, normalised to an array. */
|
||||
filesModified: string[];
|
||||
tasks: PlanTask[];
|
||||
/**
|
||||
* Legacy count. Invariant: `taskCount === tasks.length`, always. Exposed as
|
||||
* its own field so the invariant is assertable rather than assumed.
|
||||
*/
|
||||
taskCount: number;
|
||||
}
|
||||
|
||||
// ─── Task grammar ─────────────────────────────────────────────────────────────
|
||||
|
||||
// The legacy counting rule, preserved verbatim from cmdPhasePlanIndex. `g` is
|
||||
// required (we count every occurrence) and these are rebuilt per call rather
|
||||
// than hoisted to module scope: a global regex carries mutable `lastIndex`
|
||||
// state, and a shared instance is a cross-call contamination bug.
|
||||
function xmlTaskOpenings(content: string): RegExpMatchArray[] {
|
||||
return [...content.matchAll(/<task(?=[\s>])[^>]*>/gi)];
|
||||
}
|
||||
|
||||
function markdownTaskHeadings(content: string): RegExpMatchArray[] {
|
||||
return [...content.matchAll(/##\s*Task\s*\d+[^\n]*/gi)];
|
||||
}
|
||||
|
||||
/** Extract the value of one attribute from a `<task ...>` opening tag. */
|
||||
function tagAttribute(openTag: string, attr: string): string | null {
|
||||
const re = new RegExp(`\\b${attr}\\s*=\\s*"([^"]*)"|\\b${attr}\\s*=\\s*'([^']*)'`, 'i');
|
||||
const m = re.exec(openTag);
|
||||
if (!m) return null;
|
||||
const value = (m[1] ?? m[2] ?? '').trim();
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of the first `<tag>…</tag>` inside `block`, or null. Non-greedy and
|
||||
* case-insensitive; a tag that is opened but never closed yields null rather
|
||||
* than swallowing the rest of the document.
|
||||
*/
|
||||
function elementBody(block: string, tag: string): string | null {
|
||||
const re = new RegExp(`<${tag}\\s*>([\\s\\S]*?)</${tag}\\s*>`, 'i');
|
||||
const m = re.exec(block);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a `<files>` body into paths. Comma-separated per the shipped
|
||||
* `templates/phase-prompt.md` grammar; newline-separated forms are tolerated
|
||||
* too (Postel — liberal in what we accept), and the caller records nothing
|
||||
* special for them because a path list is a path list either way.
|
||||
*/
|
||||
function splitFileList(body: string | null): string[] {
|
||||
if (body === null) return [];
|
||||
return body
|
||||
.split(/[,\n]/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0);
|
||||
}
|
||||
|
||||
/** `<acceptance_criteria>` carries `- ` bullets, one criterion per line. */
|
||||
function splitCriteria(body: string | null): string[] {
|
||||
if (body === null) return [];
|
||||
return body
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => line.replace(/^[-*]\s*/, ''))
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
function collapseWhitespace(value: string | null): string | null {
|
||||
if (value === null) return null;
|
||||
const trimmed = value.replace(/\s+/g, ' ').trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `<task>` blocks. Each opening tag found by `xmlTaskOpenings` yields
|
||||
* exactly one row — the block runs from that tag to its `</task>`, or to the
|
||||
* next opening tag, or to end-of-document. Bounding on the NEXT OPENING rather
|
||||
* than only on `</task>` is what keeps an unclosed block from consuming its
|
||||
* siblings, so the row count still equals the opening count.
|
||||
*/
|
||||
function parseXmlTasks(content: string): PlanTask[] {
|
||||
const openings = xmlTaskOpenings(content);
|
||||
return openings.map((match, i) => {
|
||||
const start = match.index ?? 0;
|
||||
const openTag = match[0];
|
||||
const nextStart = i + 1 < openings.length ? (openings[i + 1].index ?? content.length) : content.length;
|
||||
const window = content.slice(start, nextStart);
|
||||
const closeIdx = window.search(/<\/task\s*>/i);
|
||||
const block = closeIdx === -1 ? window : window.slice(0, closeIdx);
|
||||
|
||||
const type = tagAttribute(openTag, 'type');
|
||||
const kind: TaskKind = type !== null && type.toLowerCase().startsWith('checkpoint')
|
||||
? TASK_KIND.CHECKPOINT
|
||||
: TASK_KIND.AUTO;
|
||||
|
||||
// A checkpoint block has no <name>/<files>/<acceptance_criteria>/<done> in
|
||||
// the shipped grammar. Reading them anyway would be harmless but dishonest:
|
||||
// the caller must be able to tell "this element is absent because this kind
|
||||
// of task has no such element" from "this element is missing and should not
|
||||
// be".
|
||||
if (kind === TASK_KIND.CHECKPOINT) {
|
||||
return {
|
||||
index: i + 1,
|
||||
kind,
|
||||
type,
|
||||
name: null,
|
||||
plannedFiles: [],
|
||||
acceptanceCriteria: [],
|
||||
done: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index: i + 1,
|
||||
kind,
|
||||
type,
|
||||
name: collapseWhitespace(elementBody(block, 'name')),
|
||||
plannedFiles: splitFileList(elementBody(block, 'files')),
|
||||
acceptanceCriteria: splitCriteria(elementBody(block, 'acceptance_criteria')),
|
||||
done: collapseWhitespace(elementBody(block, 'done')),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy fallback: `## Task N` headings, used ONLY when the document carries no
|
||||
* `<task>` blocks at all. Deliberately fence-blind, matching the counting rule
|
||||
* `cmdPhasePlanIndex` has always used — see this module's header comment.
|
||||
*/
|
||||
function parseMarkdownTasks(content: string): PlanTask[] {
|
||||
return markdownTaskHeadings(content).map((match, i) => ({
|
||||
index: i + 1,
|
||||
kind: TASK_KIND.AUTO,
|
||||
type: null,
|
||||
name: collapseWhitespace(match[0].replace(/^##\s*/, '')),
|
||||
plannedFiles: [],
|
||||
acceptanceCriteria: [],
|
||||
done: null,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Objective ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Preserved verbatim from `cmdPhasePlanIndex`: the first line following an
|
||||
* `<objective>` tag. Deliberately NOT widened to the full element body — that
|
||||
* would change `phase.plan-index`'s existing output for any multi-line
|
||||
* objective.
|
||||
*/
|
||||
function extractObjective(content: string): string | null {
|
||||
const m = content.match(/<objective>\s*\n?\s*(.+)/);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
// ─── Entry point ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The plan id for a plan FILE ENTRY, exactly as `scanPhasePlans` stores it
|
||||
* (root entries bare, nested entries `plans/`-prefixed).
|
||||
*
|
||||
* This is the established derivation from `cmdPhasePlanIndex`, moved here
|
||||
* VERBATIM (#2790) so `phase.plan-index` and `planning.inspect` cannot report
|
||||
* different ids for the same plan — a consumer correlating the two surfaces
|
||||
* needs them to join. Deliberately NOT "improved": it is a display/lookup key
|
||||
* with existing callers, and changing what it returns would be a Hyrum's-Law
|
||||
* break on `phase-plan-index`.
|
||||
*/
|
||||
function planIdFromFile(planFile: string): string {
|
||||
return planFile.replace('-PLAN.md', '').replace('PLAN.md', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one plan document.
|
||||
*
|
||||
* @param content Raw `*-PLAN.md` text.
|
||||
* @param planPath Optional path, used only to name the file in `extractFrontmatter`'s
|
||||
* truncated-frontmatter diagnostic (#1882). Callers that do not have
|
||||
* one omit it — this default IS the shape production uses from the
|
||||
* read-only query path.
|
||||
*/
|
||||
function parsePlanDocument(content: string, planPath = ''): PlanDocument {
|
||||
const fm = extractFrontmatter(content, planPath);
|
||||
|
||||
const xmlTasks = parseXmlTasks(content);
|
||||
const tasks = xmlTasks.length > 0 ? xmlTasks : parseMarkdownTasks(content);
|
||||
|
||||
const parsedWave = parseInt(fm['wave'] as string, 10);
|
||||
const declaredWave = Number.isNaN(parsedWave) ? null : parsedWave;
|
||||
|
||||
let dependsOn: string[] = [];
|
||||
const fmDeps = fm['depends_on'];
|
||||
if (Array.isArray(fmDeps)) {
|
||||
dependsOn = fmDeps.map(String);
|
||||
} else if (typeof fmDeps === 'string' && fmDeps.trim() !== '') {
|
||||
dependsOn = [fmDeps];
|
||||
}
|
||||
|
||||
let autonomous = true;
|
||||
if (fm['autonomous'] !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue comparison
|
||||
autonomous = fm['autonomous'] === 'true' || String(fm['autonomous']) === 'true';
|
||||
}
|
||||
|
||||
let filesModified: string[] = [];
|
||||
const fmFiles = fm['files_modified'] || fm['files-modified'];
|
||||
if (fmFiles) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
|
||||
filesModified = Array.isArray(fmFiles) ? fmFiles.map(String) : [String(fmFiles)];
|
||||
}
|
||||
|
||||
let agentHint: string | null = null;
|
||||
const fmAgentHint = fm['agent_hint'];
|
||||
if (fmAgentHint !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
|
||||
const hintStr = String(fmAgentHint).trim();
|
||||
agentHint = hintStr !== '' ? hintStr : null;
|
||||
}
|
||||
|
||||
return {
|
||||
objective: extractObjective(content) || (fm['objective'] as string | null) || null,
|
||||
declaredWave,
|
||||
dependsOn,
|
||||
autonomous,
|
||||
agentHint,
|
||||
filesModified,
|
||||
tasks,
|
||||
taskCount: tasks.length,
|
||||
};
|
||||
}
|
||||
|
||||
const planDocument = { TASK_KIND, parsePlanDocument, planIdFromFile };
|
||||
|
||||
// Required to merge the compile-time-only types onto the `export =` runtime
|
||||
// value; there is no ES-module-syntax way to export a type alongside a CJS
|
||||
// `export =`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
declare namespace planDocument {
|
||||
export { PlanDocument, PlanTask, TaskKind };
|
||||
}
|
||||
|
||||
export = planDocument;
|
||||
86
src/planning-command-router.cts
Normal file
86
src/planning-command-router.cts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Planning command router — CLI subcommand dispatcher for `gsd-tools planning`.
|
||||
*
|
||||
* Routes `planning inspect` (#2790) to `planningInspect.cmdPlanningInspect`.
|
||||
* Both the spaced form (`query planning inspect`) and the dotted canonical form
|
||||
* (`query planning.inspect`) reach here identically: `gsd-tools` splits a dotted
|
||||
* command on its FIRST dot before dispatch, so the router never sees the dot.
|
||||
*
|
||||
* v1 accepts NO arguments beyond the subcommand. `--raw`, `--cwd`, `--pick`,
|
||||
* `--default` and `--json-errors` are global and are spliced out of argv by
|
||||
* `gsd-tools`' own `main()` before any router runs, so anything still present at
|
||||
* `args[2]` or beyond is genuinely unrecognised and is a fail-loud USAGE error.
|
||||
* That strictness is deliberate: silently ignoring an argument a caller believed
|
||||
* was scoping the query would return a full-project snapshot presented as a
|
||||
* scoped one — a confidently wrong answer.
|
||||
*
|
||||
* Router signature `{ args, cwd, raw, error }` — identical to the other host
|
||||
* routers. Test seam: pass `_planningInspect` to inject a recording mock; the
|
||||
* `_` prefix follows this repo's established seam convention.
|
||||
*
|
||||
* ADR-457 build-at-publish: source in src/planning-command-router.cts, compiled
|
||||
* to gsd-core/bin/lib/planning-command-router.cjs (gitignored).
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningInspect = require('./planning-inspect.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import cjsCommandRouterAdapter = require('./cjs-command-router-adapter.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import io = require('./io.cjs');
|
||||
|
||||
const { routeCjsCommandFamily } = cjsCommandRouterAdapter;
|
||||
const { ERROR_REASON } = io;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PlanningInspectModule {
|
||||
cmdPlanningInspect(cwd: string, raw: boolean): void;
|
||||
}
|
||||
|
||||
interface RoutePlanningCommandOptions {
|
||||
args: string[];
|
||||
cwd: string;
|
||||
raw: boolean;
|
||||
error: (message: string, reason?: string) => void;
|
||||
/** Test seam: inject a mock planning-inspect module. Defaults to the real one. */
|
||||
_planningInspect?: PlanningInspectModule;
|
||||
}
|
||||
|
||||
// ─── Implementation ───────────────────────────────────────────────────────────
|
||||
|
||||
const PLANNING_SUBCOMMANDS = ['inspect'];
|
||||
|
||||
function routePlanningCommand({ args, cwd, raw, error, _planningInspect }: RoutePlanningCommandOptions): void {
|
||||
const mod: PlanningInspectModule = _planningInspect ?? planningInspect;
|
||||
|
||||
routeCjsCommandFamily({
|
||||
args,
|
||||
subcommands: PLANNING_SUBCOMMANDS,
|
||||
unsupported: {},
|
||||
error,
|
||||
unknownMessage: (_subcommand: string, available: string[]) =>
|
||||
`Unknown planning subcommand. Available: ${available.join(', ')}`,
|
||||
handlers: {
|
||||
inspect: () => {
|
||||
const extra = args.slice(2);
|
||||
if (extra.length > 0) {
|
||||
const offender = extra[0];
|
||||
const shape = offender.startsWith('-') ? 'flag' : 'positional argument';
|
||||
error(
|
||||
`planning inspect takes no arguments; got ${shape}: ${offender}. ` +
|
||||
'Usage: gsd-tools query planning inspect',
|
||||
ERROR_REASON.USAGE,
|
||||
);
|
||||
return;
|
||||
}
|
||||
mod.cmdPlanningInspect(cwd, raw);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export = {
|
||||
routePlanningCommand,
|
||||
PLANNING_SUBCOMMANDS,
|
||||
};
|
||||
1286
src/planning-inspect.cts
Normal file
1286
src/planning-inspect.cts
Normal file
File diff suppressed because it is too large
Load Diff
17
src/uat.cts
17
src/uat.cts
@@ -81,6 +81,19 @@ interface CurrentTest {
|
||||
|
||||
// ─── cmdAuditUat ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Select the UAT documents belonging to ONE phase directory.
|
||||
*
|
||||
* Extracted (#2790) so `cmdAuditUat` and the read-only `planning.inspect` query
|
||||
* cannot drift on which files count as this phase's UAT. `scopeToPhase` has no
|
||||
* unfiltered fallback on purpose: a phase whose own UAT file is genuinely absent
|
||||
* scopes to empty and contributes nothing, rather than picking up a stray
|
||||
* cross-phase file (#3511).
|
||||
*/
|
||||
function selectPhaseUatFiles(files: string[], phaseDirName: string): string[] {
|
||||
return scopeToPhase(files.filter((f) => f.includes('-UAT') && f.endsWith('.md')), phaseDirName);
|
||||
}
|
||||
|
||||
function cmdAuditUat(cwd: string, raw: boolean): void {
|
||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||||
const hasActivePhases = fs.existsSync(phasesDir);
|
||||
@@ -139,7 +152,7 @@ function cmdAuditUat(cwd: string, raw: boolean): void {
|
||||
// under this phase's audit-uat entry. A phase whose own UAT file is
|
||||
// genuinely absent scopes to empty and contributes nothing — correct, and
|
||||
// the reason scopeToPhase has no unfiltered fallback.
|
||||
for (const file of scopeToPhase(files.filter(f => f.includes('-UAT') && f.endsWith('.md')), dir)) {
|
||||
for (const file of selectPhaseUatFiles(files, dir)) {
|
||||
const uatFilePath = path.join(phaseDir, file);
|
||||
const content = fs.readFileSync(uatFilePath, 'utf-8');
|
||||
const items = parseUatItems(content);
|
||||
@@ -1657,6 +1670,8 @@ export = {
|
||||
cmdAuditUat,
|
||||
cmdRenderCheckpoint,
|
||||
parseCurrentTest,
|
||||
parseUatItems,
|
||||
selectPhaseUatFiles,
|
||||
buildCheckpoint,
|
||||
CHECKPOINT_FRAMES,
|
||||
CHECKPOINT_LANGUAGE_ALIASES,
|
||||
|
||||
Reference in New Issue
Block a user