* test(#2444): failing-first regression for checkpoint:* plan-structure validation Add acceptance-criteria tests covering the three canonical checkpoint task types (human-verify, decision, human-action) plus an unknown-subtype forward-compat case. Each canonical type must pass verify plan-structure when it carries its type-specific required fields (per gsd-core/references/checkpoints.md), and must be flagged when those fields are missing. Non-checkpoint tasks keep the existing <action>/<verify>/<done>/<files> requirements unchanged (AC3 regression guards). The existing 'errors when checkpoint task but autonomous is true' fixture is updated to use the canonical checkpoint:human-verify triple (<what-built>/<how-to-verify>/<resume-signal>) so it does not collide with the new per-type validator; the assertion (autonomous is not false) is unchanged. * fix(#2444): branch plan-structure validation on task type=checkpoint:* cmdVerifyPlanStructure unconditionally required <action>/<verify>/<done>/ <files> on every task, so every checkpoint:* task — which uses the checkpoint convention's type-specific fields instead — was reported as a structural error. Checkpoint-heavy phases produced walls of false findings. The fix introduces two pure helpers in verify.cts: - extractPlanTaskInfos(content): single ReDoS-safe pass over <task ...>...</task> blocks that captures BOTH the opening-tag attribute string (so the type= selector is not lost, as it is with extractTaggedBlocks) and the body, returning a typed PlanTaskInfo. - validatePlanTaskStructure(task): branches on the task's type. checkpoint:human-verify requires <what-built>/<how-to-verify>/ <resume-signal> (the canonical triple). checkpoint:decision requires <decision>/<options>/<resume-signal>. checkpoint:human-action requires <action>/<instructions>/ <verification>/<resume-signal>. Unknown checkpoint:* subtypes require only the universal <resume-signal> (forward-compat). All other types keep the historical <action>/<verify>/<done>/<files> requirements unchanged. Canonical reference: gsd-core/references/checkpoints.md. Per-type field sets validated against the documented templates in agents/gsd-planner.md and gsd-core/templates/phase-prompt.md. * fix(#2444): re-resolve body-parser to 2.3.0 in lockfile (GHSA-v422-hmwv-36x6) GHSA-v422-hmwv-36x6 (body-parser DoS via invalid limit value, low severity, published 2026-07-20T23:23:26Z) made tests/npm-integrity-gate.test.cjs (#3588: root workspace production tree has no advisories) fail any subsequent npm audit --omit=dev. The advisory affects body-parser >=2.0.0 <2.3.0 pulled transitively via @anthropic-ai/claude-agent-sdk -> @modelcontextprotocol/sdk -> express -> body-parser@2.2.2. express@5.2.1 already declares body-parser as ^2.2.1, so 2.3.0 is a valid re-resolution within express's own compatibility range — no override needed. Regenerated the lockfile via 'npm audit fix --omit=dev' which re-resolves transitive deps within their declared ranges; package.json is unchanged. Verified: npm audit --omit=dev reports 0/0/0/0/0 advisories; body-parser now reads as 2.3.0 in 'npm ls body-parser --omit=dev'. * test(#2444): close review gap-closure tests + harden type-attr charset Orthogonal review (code-review + security-review subagents) returned APPROVE on Standards and Spec. Per the playbook's zero-tolerance policy, address every Low finding: Spec gap-closures: - AC3 verbatim: add explicit <done> and <files> regression tests for non-checkpoint tasks (pre-existing tests only covered <action> and <verify>). - AC2: add checkpoint:decision missing <decision>, checkpoint:human-action missing <action>, checkpoint:human-action missing <verification> cases (the implementation enforces all of these; only one missing-field case per type was previously tested). - Remove the duplicate 'returns error for nonexistent file' test that leaked into the new describe block from the insertion edit. Security hardening (Low-sev, defense-in-depth): - Tighten the task type= attribute extractor in src/verify.cts from [^"'>\s]+ to [\w:-]+ so a hostile type= attribute cannot carry markup fragments (e.g. type=evil<fragment) into the verifier's typed JSON output. All legitimate type values (auto, tracer, manual, checkpoint:human-verify, checkpoint:decision, checkpoint:human-action, checkpoint:tdd-review) match the tighter charset. - Add adversarial regression test asserting type=evil<fragment surfaces as 'evil' (capture stops at '<'), with no markup chars (< > ( ) &) in the surfaced type field. * docs(changeset): add Fixed fragments for #2444 PR Two fragments: - sturdy-jays-tumble.md: the verify plan-structure checkpoint fix - witty-badgers-hum.md: the body-parser 2.3.0 re-resolution PR number backfilled to 0 placeholder per CLAUDE.md 'PR Number Handling'; will backfill to the real PR number immediately after gh pr create returns. * docs(changeset): backfill PR number to 2473 Per CLAUDE.md 'PR Number Handling': backfill the placeholder pr:0 with the real PR number returned by gh pr create.
This commit is contained in:
5
.changeset/sturdy-jays-tumble.md
Normal file
5
.changeset/sturdy-jays-tumble.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 2473
|
||||
---
|
||||
**`verify plan-structure` no longer false-flags checkpoint tasks for missing `<action>`/`<verify>`/`<done>`** — every `<task type="checkpoint:*">` was reported as a structural error because the verifier unconditionally required the auto-task fields. It now branches on the task's `type` attribute: `checkpoint:human-verify` requires its canonical triple (`<what-built>`/`<how-to-verify>`/`<resume-signal>`), `checkpoint:decision` requires `<decision>`/`<options>`/`<resume-signal>`, `checkpoint:human-action` requires `<action>`/`<instructions>`/`<verification>`/`<resume-signal>` (per `gsd-core/references/checkpoints.md`), and unknown `checkpoint:*` subtypes require only the universal `<resume-signal>`. Non-checkpoint tasks keep the historical `<action>`/`<verify>`/`<done>`/`<files>` requirements unchanged.
|
||||
5
.changeset/witty-badgers-hum.md
Normal file
5
.changeset/witty-badgers-hum.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 2473
|
||||
---
|
||||
**Dependency tree no longer carries a known body-parser advisory** — GHSA-v422-hmwv-36x6 (low-severity DoS via invalid `limit` value, published 2026-07-20) in `body-parser@2.2.2` was pulled transitively via `@anthropic-ai/claude-agent-sdk` → `@modelcontextprotocol/sdk` → `express` and surfaced by `npm audit --omit=dev`. Re-resolved `body-parser` to 2.3.0 in `package-lock.json` within `express`'s already-declared `^2.2.1` range; no `overrides` block needed, `package.json` is unchanged.
|
||||
59
package-lock.json
generated
59
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"bin": {
|
||||
"gsd_run": "gsd-core/bin/gsd_run",
|
||||
"gsd-core": "bin/install.js",
|
||||
"gsd-mcp-server": "bin/gsd-mcp-server.js",
|
||||
"gsd-tools": "gsd-core/bin/gsd-tools.cjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2160,20 +2161,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
"content-type": "^2.0.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.14.1",
|
||||
"raw-body": "^3.0.1",
|
||||
"type-is": "^2.0.1"
|
||||
"qs": "^6.15.2",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -2183,6 +2184,19 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
@@ -4981,17 +4995,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
"content-type": "^2.0.0",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-inject": {
|
||||
|
||||
184
src/verify.cts
184
src/verify.cts
@@ -557,6 +557,162 @@ function scanFileWideNegativeGateConflict(content: string): { warnings: string[]
|
||||
return { warnings, valid: true as const };
|
||||
}
|
||||
|
||||
// ─── Plan-task structure validation (#2444) ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Per-task structural information extracted from a PLAN.md `<tasks>` block.
|
||||
* Captures the task's `type` attribute (so `checkpoint:*` tasks validate
|
||||
* against their type-specific canonical field set per
|
||||
* `gsd-core/references/checkpoints.md`) plus presence flags for every tag the
|
||||
* validator cares about. Pure data — no I/O.
|
||||
*/
|
||||
interface PlanTaskInfo {
|
||||
name: string;
|
||||
/** Lowercased `type` attribute value, or '' when the opening tag has no type. */
|
||||
type: string;
|
||||
hasName: boolean;
|
||||
// auto-task fields
|
||||
hasFiles: boolean;
|
||||
hasAction: boolean;
|
||||
hasVerify: boolean;
|
||||
hasDone: boolean;
|
||||
// checkpoint:human-verify fields
|
||||
hasWhatBuilt: boolean;
|
||||
hasHowToVerify: boolean;
|
||||
// checkpoint:decision fields
|
||||
hasDecision: boolean;
|
||||
hasOptions: boolean;
|
||||
// checkpoint:human-action fields
|
||||
hasInstructions: boolean;
|
||||
hasVerification: boolean;
|
||||
// cross-checkpoint common
|
||||
hasResumeSignal: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single pass over `<task ...>…</task>` blocks. The body pattern is
|
||||
* ReDoS-safe stop-at-next-open (mirrors `taggedBlockPattern` in
|
||||
* markdown-sectionizer.cts): bounded attributes (`[^>]{0,1000}`) and a body
|
||||
* boundary that terminates at the NEXT `<task[\s>]` opening, so a document
|
||||
* full of unclosed `<task>` openings scans linearly. Captures both the
|
||||
* attribute string (group 1, so the `type=` selector is not lost the way it is
|
||||
* with `extractTaggedBlocks`) and the body (group 2).
|
||||
*/
|
||||
const PLAN_TASK_BLOCK_RE = /<task(\s[^>]{0,1000})?>((?:(?!<task[\s>])[\s\S])*?)<\/task>/g;
|
||||
|
||||
/**
|
||||
* Extract one `PlanTaskInfo` per `<task …>…</task>` block in `content`.
|
||||
*
|
||||
* Why a dedicated regex instead of `extractTaggedBlocks('task', true)`:
|
||||
* `extractTaggedBlocks` discards the opening tag, so the task's `type=`
|
||||
* attribute (which selects the validation branch) is lost. This helper
|
||||
* captures both the attribute string and the body in one pass, then reuses
|
||||
* `extractTaggedBlocks` on the body for sub-element extraction.
|
||||
*/
|
||||
function extractPlanTaskInfos(content: string): PlanTaskInfo[] {
|
||||
const infos: PlanTaskInfo[] = [];
|
||||
if (typeof content !== 'string' || content.length === 0) return infos;
|
||||
|
||||
PLAN_TASK_BLOCK_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = PLAN_TASK_BLOCK_RE.exec(content)) !== null) {
|
||||
const attrs = match[1] ?? '';
|
||||
const body = match[2] ?? '';
|
||||
|
||||
const typeMatch = attrs.match(/\btype\s*=\s*["']?([\w:-]+)/i);
|
||||
const type = typeMatch ? typeMatch[1].toLowerCase() : '';
|
||||
|
||||
const nameArr = extractTaggedBlocks(body, 'name');
|
||||
const hasName = nameArr.length > 0;
|
||||
const name = hasName ? nameArr[0].trim() : '';
|
||||
|
||||
infos.push({
|
||||
name,
|
||||
type,
|
||||
hasName,
|
||||
hasFiles: /<files>/.test(body),
|
||||
hasAction: /<action>/.test(body),
|
||||
hasVerify: /<verify>/.test(body),
|
||||
hasDone: /<done>/.test(body),
|
||||
hasWhatBuilt: /<what-built>/.test(body),
|
||||
hasHowToVerify: /<how-to-verify>/.test(body),
|
||||
hasDecision: /<decision>/.test(body),
|
||||
hasOptions: /<options>/.test(body),
|
||||
hasInstructions: /<instructions>/.test(body),
|
||||
hasVerification: /<verification>/.test(body),
|
||||
hasResumeSignal: /<resume-signal>/.test(body),
|
||||
});
|
||||
|
||||
// Guard against zero-length matches looping forever.
|
||||
if (match.index === PLAN_TASK_BLOCK_RE.lastIndex) {
|
||||
PLAN_TASK_BLOCK_RE.lastIndex++;
|
||||
}
|
||||
}
|
||||
return infos;
|
||||
}
|
||||
|
||||
function isCheckpointType(type: string): boolean {
|
||||
return type.startsWith('checkpoint:');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one plan task's structure against its type-specific canonical field
|
||||
* set (per `gsd-core/references/checkpoints.md`):
|
||||
* - `checkpoint:human-verify` requires `<what-built>` / `<how-to-verify>` /
|
||||
* `<resume-signal>` (the "checkpoint triple").
|
||||
* - `checkpoint:decision` requires `<decision>` / `<options>` /
|
||||
* `<resume-signal>`.
|
||||
* - `checkpoint:human-action` requires `<action>` / `<instructions>` /
|
||||
* `<verification>` / `<resume-signal>`.
|
||||
* - Unknown `checkpoint:*` subtypes require only the universal
|
||||
* `<resume-signal>` (forward-compat — newer checkpoint types registered
|
||||
* in the reference don't need a verifier change to pass structure
|
||||
* validation).
|
||||
* - All other types (`auto`, `tracer`, `manual`, bare `<task>`, …) keep the
|
||||
* historical `<action>` / `<verify>` / `<done>` / `<files>` requirements.
|
||||
*/
|
||||
function validatePlanTaskStructure(task: PlanTaskInfo): { errors: string[]; warnings: string[] } {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const taskName = task.hasName ? task.name : 'unnamed';
|
||||
|
||||
if (!task.hasName) {
|
||||
errors.push('Task missing <name> element');
|
||||
}
|
||||
|
||||
if (isCheckpointType(task.type)) {
|
||||
if (!task.hasResumeSignal) {
|
||||
errors.push(`Task '${taskName}' missing <resume-signal>`);
|
||||
}
|
||||
switch (task.type) {
|
||||
case 'checkpoint:human-verify':
|
||||
if (!task.hasWhatBuilt) errors.push(`Task '${taskName}' missing <what-built>`);
|
||||
if (!task.hasHowToVerify) errors.push(`Task '${taskName}' missing <how-to-verify>`);
|
||||
break;
|
||||
case 'checkpoint:decision':
|
||||
if (!task.hasDecision) errors.push(`Task '${taskName}' missing <decision>`);
|
||||
if (!task.hasOptions) errors.push(`Task '${taskName}' missing <options>`);
|
||||
break;
|
||||
case 'checkpoint:human-action':
|
||||
if (!task.hasAction) errors.push(`Task '${taskName}' missing <action>`);
|
||||
if (!task.hasInstructions) errors.push(`Task '${taskName}' missing <instructions>`);
|
||||
if (!task.hasVerification) errors.push(`Task '${taskName}' missing <verification>`);
|
||||
break;
|
||||
default:
|
||||
// Unknown checkpoint:* subtype: <resume-signal> is the only universal
|
||||
// requirement (forward-compat).
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (!task.hasAction) errors.push(`Task '${taskName}' missing <action>`);
|
||||
if (!task.hasVerify) warnings.push(`Task '${taskName}' missing <verify>`);
|
||||
if (!task.hasDone) warnings.push(`Task '${taskName}' missing <done>`);
|
||||
if (!task.hasFiles) warnings.push(`Task '${taskName}' missing <files>`);
|
||||
}
|
||||
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
function cmdVerifyPlanStructure(cwd: string, filePath: string, raw: boolean): void {
|
||||
if (!filePath) {
|
||||
error('file path required');
|
||||
@@ -577,22 +733,20 @@ function cmdVerifyPlanStructure(cwd: string, filePath: string, raw: boolean): vo
|
||||
if (fm[field] === undefined) errors.push(`Missing required frontmatter field: ${field}`);
|
||||
}
|
||||
|
||||
const extractedTasks = extractPlanTaskInfos(content);
|
||||
const tasks: Record<string, unknown>[] = [];
|
||||
for (const taskContent of extractTaggedBlocks(content, 'task', true)) {
|
||||
const nameArr = extractTaggedBlocks(taskContent, 'name');
|
||||
const taskName = nameArr.length ? nameArr[0].trim() : 'unnamed';
|
||||
const hasFiles = /<files>/.test(taskContent);
|
||||
const hasAction = /<action>/.test(taskContent);
|
||||
const hasVerify = /<verify>/.test(taskContent);
|
||||
const hasDone = /<done>/.test(taskContent);
|
||||
|
||||
if (nameArr.length === 0) errors.push('Task missing <name> element');
|
||||
if (!hasAction) errors.push(`Task '${taskName}' missing <action>`);
|
||||
if (!hasVerify) warnings.push(`Task '${taskName}' missing <verify>`);
|
||||
if (!hasDone) warnings.push(`Task '${taskName}' missing <done>`);
|
||||
if (!hasFiles) warnings.push(`Task '${taskName}' missing <files>`);
|
||||
|
||||
tasks.push({ name: taskName, hasFiles, hasAction, hasVerify, hasDone });
|
||||
for (const task of extractedTasks) {
|
||||
const verdict = validatePlanTaskStructure(task);
|
||||
errors.push(...verdict.errors);
|
||||
warnings.push(...verdict.warnings);
|
||||
tasks.push({
|
||||
name: task.hasName ? task.name : 'unnamed',
|
||||
type: task.type,
|
||||
hasFiles: task.hasFiles,
|
||||
hasAction: task.hasAction,
|
||||
hasVerify: task.hasVerify,
|
||||
hasDone: task.hasDone,
|
||||
});
|
||||
}
|
||||
|
||||
if (tasks.length === 0) warnings.push('No <task> elements found');
|
||||
|
||||
@@ -263,12 +263,11 @@ describe('verify plan-structure command', () => {
|
||||
' <verify><automated>echo ok</automated></verify>',
|
||||
' <done>Done</done>',
|
||||
'</task>',
|
||||
'<task type="checkpoint:human-verify">',
|
||||
'<task type="checkpoint:human-verify" gate="blocking">',
|
||||
' <name>Task 2: Verify UI</name>',
|
||||
' <files>some/file.ts</files>',
|
||||
' <action>Check the UI</action>',
|
||||
' <verify><human>Visit the app</human></verify>',
|
||||
' <done>UI verified</done>',
|
||||
' <what-built>UI at localhost:3000</what-built>',
|
||||
' <how-to-verify>Visit the app</how-to-verify>',
|
||||
' <resume-signal>Type "approved"</resume-signal>',
|
||||
'</task>',
|
||||
'</tasks>',
|
||||
].join('\n');
|
||||
@@ -299,6 +298,384 @@ describe('verify plan-structure command', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// verify plan-structure — checkpoint task types (#2444)
|
||||
// A checkpoint:* task uses type-specific required fields (per
|
||||
// gsd-core/references/checkpoints.md), NOT the auto-task <action>/<verify>/<done>.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('verify plan-structure — checkpoint task types (#2444)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// Helper: wrap a task body in a complete valid PLAN.md scaffold.
|
||||
function planWithTask(taskBody, { autonomous = 'false' } = {}) {
|
||||
return [
|
||||
'---',
|
||||
'phase: 01-test',
|
||||
'plan: 01',
|
||||
'type: execute',
|
||||
'wave: 1',
|
||||
'depends_on: []',
|
||||
'files_modified: [some/file.ts]',
|
||||
`autonomous: ${autonomous}`,
|
||||
'must_haves:',
|
||||
' truths:',
|
||||
' - "something"',
|
||||
'---',
|
||||
'',
|
||||
'<tasks>',
|
||||
taskBody,
|
||||
'</tasks>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function runVerify(planContent) {
|
||||
const planPath = path.join(tmpDir, '.planning', 'phases', '01-test', '01-01-PLAN.md');
|
||||
fs.writeFileSync(planPath, planContent);
|
||||
const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
return JSON.parse(result.output);
|
||||
}
|
||||
|
||||
// ── AC1: canonical checkpoint tasks pass with zero findings ────────────────
|
||||
|
||||
test('checkpoint:human-verify with canonical triple passes (AC1)', () => {
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type="checkpoint:human-verify" gate="blocking">',
|
||||
' <name>Checkpoint: verify UI</name>',
|
||||
' <what-built>Dashboard at localhost:3000</what-built>',
|
||||
' <how-to-verify>Visit /dashboard, check layout</how-to-verify>',
|
||||
' <resume-signal>Type "approved" or describe issues</resume-signal>',
|
||||
'</task>',
|
||||
].join('\n')));
|
||||
|
||||
assert.strictEqual(output.valid, true, `expected valid; errors: ${JSON.stringify(output.errors)}`);
|
||||
assert.deepStrictEqual(output.errors, [], `expected no errors; got: ${JSON.stringify(output.errors)}`);
|
||||
assert.strictEqual(output.task_count, 1, 'should count the checkpoint task');
|
||||
});
|
||||
|
||||
test('checkpoint:decision with canonical fields passes (AC1)', () => {
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type="checkpoint:decision" gate="blocking">',
|
||||
' <name>Checkpoint: pick auth provider</name>',
|
||||
' <decision>Select authentication provider</decision>',
|
||||
' <context>Need user authentication.</context>',
|
||||
' <options>',
|
||||
' <option id="supabase"><name>Supabase Auth</name><pros>Built-in</pros><cons>Lock-in</cons></option>',
|
||||
' <option id="clerk"><name>Clerk</name><pros>DX</pros><cons>Paid</cons></option>',
|
||||
' </options>',
|
||||
' <resume-signal>Select: supabase or clerk</resume-signal>',
|
||||
'</task>',
|
||||
].join('\n')));
|
||||
|
||||
assert.strictEqual(output.valid, true, `expected valid; errors: ${JSON.stringify(output.errors)}`);
|
||||
assert.deepStrictEqual(output.errors, [], `expected no errors; got: ${JSON.stringify(output.errors)}`);
|
||||
});
|
||||
|
||||
test('checkpoint:human-action with canonical fields passes (AC1)', () => {
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type="checkpoint:human-action" gate="blocking">',
|
||||
' <name>Checkpoint: complete email verification</name>',
|
||||
' <action>Click the verification link in your inbox</action>',
|
||||
' <instructions>I created the account; check your email.</instructions>',
|
||||
' <verification>API key works via curl</verification>',
|
||||
' <resume-signal>Type "done" when email verified</resume-signal>',
|
||||
'</task>',
|
||||
].join('\n')));
|
||||
|
||||
assert.strictEqual(output.valid, true, `expected valid; errors: ${JSON.stringify(output.errors)}`);
|
||||
assert.deepStrictEqual(output.errors, [], `expected no errors; got: ${JSON.stringify(output.errors)}`);
|
||||
});
|
||||
|
||||
test('unknown checkpoint:* subtype passes with just <resume-signal> (forward-compat)', () => {
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type="checkpoint:custom-future-type">',
|
||||
' <name>Checkpoint: future</name>',
|
||||
' <resume-signal>Type "ok"</resume-signal>',
|
||||
'</task>',
|
||||
].join('\n')));
|
||||
|
||||
assert.strictEqual(output.valid, true, `expected valid; errors: ${JSON.stringify(output.errors)}`);
|
||||
assert.deepStrictEqual(output.errors, [], `expected no errors; got: ${JSON.stringify(output.errors)}`);
|
||||
});
|
||||
|
||||
test('mixed plan: auto task + checkpoint:human-verify task passes (AC1 realistic)', () => {
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type="auto">',
|
||||
' <name>Task 1: build dashboard</name>',
|
||||
' <files>src/dashboard.ts</files>',
|
||||
' <action>Scaffold the dashboard</action>',
|
||||
' <verify><automated>npm test</automated></verify>',
|
||||
' <done>Dashboard renders</done>',
|
||||
'</task>',
|
||||
'<task type="checkpoint:human-verify" gate="blocking">',
|
||||
' <name>Checkpoint: visual review</name>',
|
||||
' <what-built>Dashboard at localhost:3000</what-built>',
|
||||
' <how-to-verify>Visit /dashboard, check responsive layout</how-to-verify>',
|
||||
' <resume-signal>Type "approved"</resume-signal>',
|
||||
'</task>',
|
||||
].join('\n')));
|
||||
|
||||
assert.strictEqual(output.valid, true, `expected valid; errors: ${JSON.stringify(output.errors)}`);
|
||||
assert.deepStrictEqual(output.errors, [], `expected no errors; got: ${JSON.stringify(output.errors)}`);
|
||||
assert.strictEqual(output.task_count, 2, 'should count both tasks');
|
||||
});
|
||||
|
||||
// ── AC2: checkpoint tasks missing required fields are still flagged ────────
|
||||
|
||||
test('checkpoint:human-verify missing <how-to-verify> is flagged (AC2)', () => {
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type="checkpoint:human-verify" gate="blocking">',
|
||||
' <name>Checkpoint: verify UI</name>',
|
||||
' <what-built>UI at localhost:3000</what-built>',
|
||||
' <resume-signal>Type "approved"</resume-signal>',
|
||||
'</task>',
|
||||
].join('\n')));
|
||||
|
||||
assert.strictEqual(output.valid, false, 'should be invalid');
|
||||
assert.ok(
|
||||
output.errors.some(e => e.includes('missing <how-to-verify>')),
|
||||
`Expected "missing <how-to-verify>" error: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('checkpoint:human-verify missing <what-built> is flagged (AC2)', () => {
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type="checkpoint:human-verify" gate="blocking">',
|
||||
' <name>Checkpoint: verify UI</name>',
|
||||
' <how-to-verify>Visit /dashboard</how-to-verify>',
|
||||
' <resume-signal>Type "approved"</resume-signal>',
|
||||
'</task>',
|
||||
].join('\n')));
|
||||
|
||||
assert.strictEqual(output.valid, false, 'should be invalid');
|
||||
assert.ok(
|
||||
output.errors.some(e => e.includes('missing <what-built>')),
|
||||
`Expected "missing <what-built>" error: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('checkpoint:decision missing <options> is flagged (AC2)', () => {
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type="checkpoint:decision" gate="blocking">',
|
||||
' <name>Checkpoint: pick</name>',
|
||||
' <decision>Select provider</decision>',
|
||||
' <resume-signal>Select: a or b</resume-signal>',
|
||||
'</task>',
|
||||
].join('\n')));
|
||||
|
||||
assert.strictEqual(output.valid, false, 'should be invalid');
|
||||
assert.ok(
|
||||
output.errors.some(e => e.includes('missing <options>')),
|
||||
`Expected "missing <options>" error: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('checkpoint:human-action missing <instructions> is flagged (AC2)', () => {
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type="checkpoint:human-action" gate="blocking">',
|
||||
' <name>Checkpoint: act</name>',
|
||||
' <action>Do the thing</action>',
|
||||
' <verification>curl returns 200</verification>',
|
||||
' <resume-signal>Type "done"</resume-signal>',
|
||||
'</task>',
|
||||
].join('\n')));
|
||||
|
||||
assert.strictEqual(output.valid, false, 'should be invalid');
|
||||
assert.ok(
|
||||
output.errors.some(e => e.includes('missing <instructions>')),
|
||||
`Expected "missing <instructions>" error: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('checkpoint:decision missing <decision> is flagged (AC2)', () => {
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type="checkpoint:decision" gate="blocking">',
|
||||
' <name>Checkpoint: pick</name>',
|
||||
' <options>',
|
||||
' <option id="a"><name>A</name><pros>p</pros><cons>c</cons></option>',
|
||||
' </options>',
|
||||
' <resume-signal>Select: a</resume-signal>',
|
||||
'</task>',
|
||||
].join('\n')));
|
||||
|
||||
assert.strictEqual(output.valid, false, 'should be invalid');
|
||||
assert.ok(
|
||||
output.errors.some(e => e.includes('missing <decision>')),
|
||||
`Expected "missing <decision>" error: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('checkpoint:human-action missing <action> is flagged (AC2)', () => {
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type="checkpoint:human-action" gate="blocking">',
|
||||
' <name>Checkpoint: act</name>',
|
||||
' <instructions>Do it.</instructions>',
|
||||
' <verification>curl returns 200</verification>',
|
||||
' <resume-signal>Type "done"</resume-signal>',
|
||||
'</task>',
|
||||
].join('\n')));
|
||||
|
||||
assert.strictEqual(output.valid, false, 'should be invalid');
|
||||
assert.ok(
|
||||
output.errors.some(e => e.includes('missing <action>')),
|
||||
`Expected "missing <action>" error: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('checkpoint:human-action missing <verification> is flagged (AC2)', () => {
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type="checkpoint:human-action" gate="blocking">',
|
||||
' <name>Checkpoint: act</name>',
|
||||
' <action>Do it</action>',
|
||||
' <instructions>Do it.</instructions>',
|
||||
' <resume-signal>Type "done"</resume-signal>',
|
||||
'</task>',
|
||||
].join('\n')));
|
||||
|
||||
assert.strictEqual(output.valid, false, 'should be invalid');
|
||||
assert.ok(
|
||||
output.errors.some(e => e.includes('missing <verification>')),
|
||||
`Expected "missing <verification>" error: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('any checkpoint:* missing <resume-signal> is flagged (AC2)', () => {
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type="checkpoint:human-verify" gate="blocking">',
|
||||
' <name>Checkpoint: verify UI</name>',
|
||||
' <what-built>UI</what-built>',
|
||||
' <how-to-verify>Visit</how-to-verify>',
|
||||
'</task>',
|
||||
].join('\n')));
|
||||
|
||||
assert.strictEqual(output.valid, false, 'should be invalid');
|
||||
assert.ok(
|
||||
output.errors.some(e => e.includes('missing <resume-signal>')),
|
||||
`Expected "missing <resume-signal>" error: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('checkpoint task without a type attribute still gets non-checkpoint rules (regression guard)', () => {
|
||||
// A bare <task> (no type=) is NOT treated as a checkpoint; current rules apply.
|
||||
const output = runVerify(planWithTask([
|
||||
'<task>',
|
||||
' <name>Bare task</name>',
|
||||
' <files>x.ts</files>',
|
||||
' <verify><automated>echo ok</automated></verify>',
|
||||
' <done>ok</done>',
|
||||
'</task>',
|
||||
].join('\n')));
|
||||
|
||||
assert.strictEqual(output.valid, false, 'should be invalid (missing <action>)');
|
||||
assert.ok(
|
||||
output.errors.some(e => e.includes('missing <action>')),
|
||||
`Expected "missing <action>" error: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── AC3: non-checkpoint tasks missing fields are still flagged (no regression) ──
|
||||
|
||||
test('non-checkpoint task missing <action> is still flagged (AC3)', () => {
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type="auto">',
|
||||
' <name>Task 1: no action</name>',
|
||||
' <verify><automated>echo ok</automated></verify>',
|
||||
' <done>Done</done>',
|
||||
'</task>',
|
||||
].join('\n'), { autonomous: 'true' }));
|
||||
|
||||
assert.ok(
|
||||
output.errors.some(e => e.includes('missing <action>')),
|
||||
`Expected "missing <action>" error: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('non-checkpoint task missing <verify> still warns (AC3)', () => {
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type="auto">',
|
||||
' <name>Task 1: no verify</name>',
|
||||
' <files>x.ts</files>',
|
||||
' <action>Do it</action>',
|
||||
' <done>Done</done>',
|
||||
'</task>',
|
||||
].join('\n'), { autonomous: 'true' }));
|
||||
|
||||
assert.ok(
|
||||
output.warnings.some(w => w.includes('missing <verify>')),
|
||||
`Expected "missing <verify>" warning: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('non-checkpoint task missing <done> still warns (AC3)', () => {
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type="auto">',
|
||||
' <name>Task 1: no done</name>',
|
||||
' <files>x.ts</files>',
|
||||
' <action>Do it</action>',
|
||||
' <verify><automated>echo ok</automated></verify>',
|
||||
'</task>',
|
||||
].join('\n'), { autonomous: 'true' }));
|
||||
|
||||
assert.ok(
|
||||
output.warnings.some(w => w.includes('missing <done>')),
|
||||
`Expected "missing <done>" warning: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('non-checkpoint task missing <files> still warns (AC3)', () => {
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type="auto">',
|
||||
' <name>Task 1: no files</name>',
|
||||
' <action>Do it</action>',
|
||||
' <verify><automated>echo ok</automated></verify>',
|
||||
' <done>Done</done>',
|
||||
'</task>',
|
||||
].join('\n'), { autonomous: 'true' }));
|
||||
|
||||
assert.ok(
|
||||
output.warnings.some(w => w.includes('missing <files>')),
|
||||
`Expected "missing <files>" warning: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── Security: type-attribute charset is bounded (no markup injection) ──────
|
||||
|
||||
test('task type attribute with hostile markup fragment is not surfaced unsanitized', () => {
|
||||
// Per CONTRIBUTING.md §"Security and prompt-injection surfaces": a hostile
|
||||
// PLAN.md cannot inject unclosed-tag fragments into the verifier's typed
|
||||
// JSON output via the type= attribute. The charset [a-zA-Z0-9_:-] rejects
|
||||
// '<', '>', '(', '&', etc., so a payload like type=evil<fragment captures
|
||||
// only `evil` (the `<` terminates the match); the surfaced `type` field
|
||||
// carries no markup.
|
||||
const output = runVerify(planWithTask([
|
||||
'<task type=evil<fragment>',
|
||||
' <name>Hostile</name>',
|
||||
' <action>do</action>',
|
||||
' <verify><automated>echo ok</automated></verify>',
|
||||
' <done>ok</done>',
|
||||
'</task>',
|
||||
].join('\n'), { autonomous: 'true' }));
|
||||
|
||||
const hostile = output.tasks.find(t => t.name === 'Hostile');
|
||||
assert.ok(hostile, `Expected to find Hostile task in output.tasks: ${JSON.stringify(output.tasks)}`);
|
||||
assert.ok(
|
||||
!/[<>()&]/.test(hostile.type),
|
||||
`Expected type to contain no markup chars; got: ${JSON.stringify(hostile.type)}`
|
||||
);
|
||||
assert.strictEqual(hostile.type, 'evil', `Expected capture to stop at '<'; got: ${JSON.stringify(hostile.type)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// verify phase-completeness command
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user