* test(#2847): add failing-first regression tests for gap-closure frontmatter schema gap --gaps did not load a machine-checked requirement for gap_closure: true. The planner's only validation gate (frontmatter.validate --schema plan) never required it, and plan-phase.md's downstream_consumer contract never mentioned it either, so gap-closure plans could pass validation while missing the field that /gsd:execute-phase --gaps-only filters on. These tests are RED against current production code: no plan-gap-closure schema exists yet, and neither agents/gsd-planner.md's validate_plan step nor plan-phase.md's downstream_consumer block references gap_closure conditionally. * fix(#2847): enforce gap_closure via plan-gap-closure schema --gaps did not load a machine-checked requirement for gap_closure: true. The planner's only validation gate (frontmatter.validate --schema plan) never required it, so a gap-closure plan could pass validation while missing the field /gsd:execute-phase --gaps-only filters on, silently spawning zero executors. Add a plan-gap-closure schema (every plan-required field plus gap_closure) and make the planner's validate_plan step select it when gap_closure mode is active, plan otherwise. Standard/reviews-mode plans are unaffected: plan's required fields are unchanged. plan-phase.md's downstream_consumer block was investigated for a symmetric mention but deliberately left untouched: it sits 36 bytes under the frozen ADR-857 PRE_PHASE6 ceiling and the validate_plan step in gsd-planner.md is the actual call site, needing no help from plan-phase.md's prose. * fix(#2847): compact validate_plan edit under gsd-planner.md size caps Merging origin/next (7 commits, including #2775's gsd-planner.md STRIDE-row edit) left only 22 chars of headroom under four separate hard-coded 49152-char caps on gsd-planner.md (planner-decomposition, precondition-element, reversibility-tagging, security.test.cjs). The verbose validate_plan prose from the previous commit overran all four. Compact the edit to a single line (net +17 chars vs origin/next) while keeping the functional content: schema name, mode condition, and the unchanged base required-fields list. Also: - Fix a real bug in the fix-2847 negative-assertion test: plan-phase.md mentions the literal string "<downstream_consumer>" twice in backtick-quoted prose before the actual opening tag, so a plain indexOf() grabbed the wrong start position and swallowed ~10KB of unrelated content (including a "gap_closure" hit in a Mode: enum line), producing a false failure. Anchor on the tag starting its own line instead. - Merge the emitted-drift-ack fragment for gsd-planner.md with the #2775 fragment brought in by the merge (both named the same path; two ack sources may never name the same path) and correct its byte delta to the actual final number. * fix(#2847): drop stale merge-inherited emitted-drift-ack fragments Merging origin/next brought in three new emitted-drift-ack fragments (1700, 2658, 2775) relative to this branch's fork point. #2775 collided with my own gsd-planner.md key and was already consolidated. #1700 and #2658 don't collide, but none of their entries name a path this branch's actual diff touches (git diff --name-only origin/next...HEAD) — the ripples they explain are already baked into the current next baseline, so they explain nothing here and the emitted-attribution gate correctly reports them as stale (verified live: spike-wrap-up.md from #1700). Delete both fragment files. Neither is referenced by any test beyond a stray comment pointing at an unrelated diagnosis artifact path, not the ack fragment itself. * fix(#2847): restore merge-inherited ack fragments deleted in error 1700-spike-manifest-idea-scoping.json and 2658-trae-instruction-file-path.json exist on origin/next (landed via other, already-merged PRs) and arrived on this branch unchanged via the origin/next merge. The previous commit deleted them to satisfy a stale-acknowledgment finding, but the finding was about the acks being MODIFIED in this diff, not about needing to stop existing — deleting them would have silently reverted two other PRs' already-merged, already-justified byte growth. Restored byte-identical to origin/next (git diff origin/next -- <path> empty for both). 2775-planner-package-legitimacy-gate.json stays consolidated into 2847-gap-closure-validate-plan-step.json: that one was a genuine hard key-collision (two fragments naming the same gsd-planner.md path, which lint-emitted-drift-ack hard-blocks), not a pass-through case. * fix(#2847): bind --schema to gap_closure mode, not hardcode it Prior revision left the validate_plan bash invocation unconditional (--schema plan)) while only the prose sentence above it described the gap_closure-mode branch. An agent executing the shown line literally always validated with the plan schema, so a gap-closure plan missing gap_closure: true still reported valid:true — #2847 reproducing unchanged. Existing tests didn't catch it: they checked for substring presence anywhere in the step, which the prose alone satisfied. Change the bash line to --schema "$SCHEMA" — a real shell-variable reference in the same placeholder convention this file already uses for "$PLAN_PATH" (never literally assigned; the agent resolves it from context, same as PLAN_PATH). A genuine if/then bash conditional already exists elsewhere in this file (load_project_state's INIT @file: check), confirming executed conditionals, not merely descriptive prose, are the established pattern here. Rewrite the regression test to assert on the bash block's literal --schema argument: reject a hardcoded plan) or plan-gap-closure) literal, require a variable reference, and require the step's prose to bind that same variable name. Verified RED against the prior revision and GREEN against this one before committing either state. * fix(#2847): CRLF-safe tests, drop unexplained ack, require gap_closure=true Four items from independent review, all landing together per request: 1. The #2847 regression test file had two CRLF-fragile regexes (local/no-crlf-fragile-split): a bare \n on readFileSync content means a real \r\n checkout returns invocationLine === null and all four executable-content assertions stop asserting anything while still reporting green. Both now use \r?\n. Prior lint report of exit 0 was a false green from a stale eslint cache. 2. The 2847 drift-ack fragment explained nothing: a direct edit to agents/gsd-planner.md is self-explaining, drift-acks exist for emitted-artifact ripple that cannot be traced to a changed source path. Deleted. Restored the 2775 fragment byte-identical to next (git diff --name-status next...HEAD -- tests/emitted-drift-acks/ now prints nothing) — it only conflicted with the now-deleted 2847 fragment, never needed touching itself. 3. plan-gap-closure validated gap_closure by PRESENCE only (unchanged since the original #2847 fix), so gap_closure: false satisfied it — --gaps-only filters strictly on gap_closure === true, so a false-valued plan still validates green and still spawns zero executors: #2847's exact reported symptom, one value away. Added an optional requiredValues map to FRONTMATTER_SCHEMAS; plan-gap-closure now requires gap_closure to equal the string "true" (extractFrontmatter parses every scalar as a string) in addition to being present. Every other schema/field keeps the original presence-only contract. The row that had documented the hole instead of closing it now asserts the fix; a matching unit test locks requiredValues on FRONTMATTER_SCHEMAS. 4. The "names the plain plan schema" assertion matched the bare substring "plan" anywhere in the step, which verify.plan-structure satisfies incidentally a few lines below — the assertion could not fail even if the plain-plan branch were deleted from the prose. Changed to match the standalone backtick-quoted plan token. * fix(#2847): remove contradictory leftover assertion in Row 6 test The gap_closure:false test asserted !present.includes('gap_closure') (correct — matches the implementation's fold-wrong-value-into-missing semantics) immediately followed by a stale, unedited leftover from an earlier draft of the same test asserting the opposite: present.includes('gap_closure'). The second could never pass once the first did; both were in the same diff. Verified before committing: searched every consumer of frontmatter.validate output (agents/gsd-planner.md, docs/CLI-TOOLS.md, all other test files) for any read of the present field — none exist. Nothing depends on "present" meaning "physically exists regardless of value correctness", so the implementation's fold (present/missing stay a full partition of required) is the right call; the test needed to agree with it, not the other way around. Manually replayed all six rows in the plan-gap-closure describe block against the built CLI to confirm each now passes. * fix(#2847): prototype-key guard, wrong-value diagnostic, doc fixes, vacuous tests Six items from an independent SHIP_VERDICT:no review, landing together per request: 1. Prototype-key crash (src/frontmatter.cts): FRONTMATTER_SCHEMAS[schemaName] was an unguarded lookup, so --schema __proto__ (also constructor, toString, hasOwnProperty, valueOf) resolved to an Object.prototype member instead of undefined, the `!schema` check never fired, and the command crashed with an uncaught TypeError and a stack trace instead of "Unknown schema". Now reachable from prompt state (--schema is an agent-bound $SCHEMA), not just an unreachable literal. Guarded with Object.prototype.hasOwnProperty.call before the lookup, checked and rejected before assignment so `schema`'s type stays non-optional. Added a test for all five prototype keys. 2. Wrong-value diagnostic (src/frontmatter.cts, agents/gsd-planner.md): the strict gap_closure === "true" check from the previous fix was correct (fail-closed) but silent about WHY — a plan with gap_closure: True got "missing", indistinguishable from genuinely absent, even though the field is plainly in the file. Added an `invalidValue` field to the validate JSON (present but wrong-valued, disjoint from missing/present) and updated validate_plan's prose to state the exact required literal and explain invalidValue, within the remaining byte budget (49130/49152). 3. docs/reference/plan-md.md: fixed three inaccuracies in the gap_closure row — "this field plus every field above" implied `requirements` (documented Required: Yes) is schema-enforced, it is not; "Type: boolean" implied YAML True/TRUE/yes/1 are accepted, they are rejected (exact string match on literal lowercase true); "must never carry it" stated an unenforced rule as fact. Also switched /gsd:plan-phase and /gsd:execute-phase to the house-style hyphen form for docs/. 4. Vacuous negative assertions (tests/fix-2847-gap-closure-frontmatter.test.cjs): RegExp#test coerces a null invocationLine to the string "null", so both hardcoded-literal checks passed vacuously even if the step or its bash block were deleted entirely. Added a truthy precondition check first. 5. Deleted vacuous/pass-always tests: four in tests/frontmatter.unit.test.cjs strictly subsumed by (or, for the "superset" test, tautologically guaranteed by the same spread as) the deepEqual exact-list test; two describe blocks in the #2847 regression file that were already GREEN at the RED commit (5e5897cd2f17ebf2fc55757bae651bbbeb236289) and pinned untouched files rather than covering anything this change altered — one of them additionally forbade any future legitimate gap_closure mention in plan-phase.md, a trap for whoever frees up that file's byte budget later. 6. .changeset/clever-newts-wake.md: switched /gsd:plan-phase and /gsd:execute-phase to /gsd-plan-phase and /gsd-execute-phase — changesets render verbatim into CHANGELOG.md with no converter in the path, so the colon form would have reached readers naming a command no runtime registers. * chore(#2847): backfill changeset pr number (#3018) --------- Co-authored-by: sim <sim@local>
221 lines
12 KiB
JavaScript
221 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
// allow-test-rule: source-text-is-the-product (see #2847)
|
|
// agents/gsd-planner.md is the deployed runtime prompt contract — the planner
|
|
// agent literally executes this markdown. Testing its text content tests the
|
|
// deployed contract, per the CONTRIBUTING.md exception matrix and the existing
|
|
// precedent in tests/plan-phase-drift-guard.test.cjs and
|
|
// tests/edge-probe-planner-contract.test.cjs.
|
|
|
|
/**
|
|
* Regression tests for #2847
|
|
*
|
|
* "--gaps does not load planner-gap-closure.md, so generated gap plans may
|
|
* miss gap_closure metadata"
|
|
*
|
|
* Root cause: the planner's only machine-checked validation gate
|
|
* (`gsd_run query frontmatter.validate "$PLAN_PATH" --schema plan`) never
|
|
* required `gap_closure`. The only place `gap_closure: true` was actually
|
|
* documented as required was prose in a conditionally-loaded reference file
|
|
* (gsd-core/references/planner-gap-closure.md) plus an unvalidated checklist
|
|
* item — neither backed by a deterministic gate.
|
|
*
|
|
* Fix:
|
|
* - src/frontmatter.cts: new `plan-gap-closure` FRONTMATTER_SCHEMAS entry
|
|
* (covered behaviorally in tests/frontmatter-cli.test.cjs and
|
|
* tests/frontmatter.unit.test.cjs — this file covers the prompt-level wiring
|
|
* that selects it).
|
|
* - agents/gsd-planner.md `<step name="validate_plan">`: the bash invocation
|
|
* now reads `--schema "$SCHEMA"` — a real shell-variable reference, bound in
|
|
* the same style as the file's existing `"$PLAN_PATH"` convention — instead
|
|
* of a hardcoded literal. An earlier revision left the bash line unconditional
|
|
* (`--schema plan)`, a copy-executable no-op) while only the prose sentence
|
|
* above it mentioned the conditional; that revision satisfied every
|
|
* substring-presence check but never actually selected plan-gap-closure at
|
|
* runtime. Caught by review, not by tests — see the describe block below for
|
|
* the executable-content assertions written specifically to catch it.
|
|
*
|
|
* Deliberately NOT touched: gsd-core/workflows/plan-phase.md's
|
|
* `<downstream_consumer>` block. An earlier draft of this fix added a
|
|
* gap_closure mention there too (mirroring plan-phase.md's existing
|
|
* `<review_incorporation_contract>` mode-scoped-block pattern for reviews
|
|
* mode), but plan-phase.md sits only 36 bytes under the hard ADR-857
|
|
* PRE_PHASE6 ceiling (tests/phase6-capstone-conformance.test.cjs,
|
|
* `PRE_PHASE6['plan-phase.md'] = 94519`) and cannot absorb the ~330-byte
|
|
* addition. The `<step name="validate_plan">` fix in gsd-planner.md is the
|
|
* actual call site and is sufficient on its own: the planner already tracks
|
|
* gap_closure mode internally (its own `<step name="identify_phase">` switches
|
|
* to gap_closure_mode on `--gaps`), so the schema selection does not depend on
|
|
* plan-phase.md's prose at all. See .gsd/bug/fix-2847-gap-closure-frontmatter/10-diagnosis.md.
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const PLANNER_AGENT_PATH = path.join(__dirname, '..', 'agents', 'gsd-planner.md');
|
|
|
|
function readFile(p) {
|
|
return fs.readFileSync(p, 'utf-8');
|
|
}
|
|
|
|
function extractStep(content, stepName) {
|
|
const marker = `<step name="${stepName}">`;
|
|
const start = content.indexOf(marker);
|
|
if (start === -1) return null;
|
|
const end = content.indexOf('</step>', start);
|
|
if (end === -1) return null;
|
|
return content.slice(start, end + '</step>'.length);
|
|
}
|
|
|
|
/**
|
|
* Extract the FIRST ```bash ... ``` fenced block from a step's text. Returns null
|
|
* if no fenced bash block is found.
|
|
*/
|
|
function extractFirstBashBlock(stepText) {
|
|
const m = /```bash\r?\n([\s\S]*?)```/.exec(stepText);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
/**
|
|
* Find the literal line, within a bash block, that invokes `frontmatter.validate`.
|
|
* Returns null if not found.
|
|
*/
|
|
function findValidateInvocationLine(bashBlock) {
|
|
if (!bashBlock) return null;
|
|
return bashBlock.split('\n').find((l) => l.includes('frontmatter.validate')) || null;
|
|
}
|
|
|
|
// ─── agents/gsd-planner.md: validate_plan step BINDS schema to mode (#2847) ──
|
|
//
|
|
// This describe block asserts on the EXECUTABLE content of the step — the literal
|
|
// argument passed to `--schema` in the fenced bash block the agent actually runs —
|
|
// not on whether explanatory words appear anywhere in the step's prose. A prose
|
|
// sentence like "use plan-gap-closure in gap_closure mode, else plan" sitting next
|
|
// to an UNCONDITIONAL `--schema plan)` line satisfies every substring-presence
|
|
// check imaginable while the agent still only ever executes `--schema plan`. That
|
|
// exact shape shipped in an earlier revision of this fix and was caught by review,
|
|
// not by tests — these tests are written specifically to catch it mechanically:
|
|
// verified RED against that revision (`--schema plan)` hardcoded in the bash
|
|
// block, `--schema plan-gap-closure` only in the prose sentence above it) before
|
|
// the bash block was changed to `--schema "$SCHEMA"`.
|
|
|
|
describe('#2847: gsd-planner.md validate_plan step BINDS --schema to gap_closure mode (executable content, not prose)', () => {
|
|
const plannerContent = readFile(PLANNER_AGENT_PATH);
|
|
const validateStep = extractStep(plannerContent, 'validate_plan');
|
|
const bashBlock = extractFirstBashBlock(validateStep || '');
|
|
const invocationLine = findValidateInvocationLine(bashBlock);
|
|
|
|
test('validate_plan step exists and has a fenced bash block invoking frontmatter.validate', () => {
|
|
assert.ok(validateStep, '<step name="validate_plan"> must exist in agents/gsd-planner.md');
|
|
assert.ok(bashBlock, 'validate_plan step must have a ```bash fenced block');
|
|
assert.ok(invocationLine, 'validate_plan step bash block must invoke frontmatter.validate');
|
|
});
|
|
|
|
test('the --schema argument in the bash invocation is NOT a hardcoded literal', () => {
|
|
// Precondition: both regex checks below use `.test(invocationLine)`, and
|
|
// RegExp#test coerces a null/undefined argument to the STRING "null"/
|
|
// "undefined" rather than throwing — neither hardcoded-literal pattern
|
|
// matches that string, so both negative assertions would pass vacuously
|
|
// (reporting "not hardcoded") even if the step or its bash block were
|
|
// deleted entirely. Fail loudly on that precondition first so a deleted
|
|
// step is reported as exactly that, not as a false "fix confirmed".
|
|
assert.ok(invocationLine, 'precondition: invocationLine must be found (see the first test in this block)');
|
|
|
|
// This is the exact regression: a prior revision had this line read
|
|
// `--schema plan)` verbatim — a plain, hardcoded, always-the-same-value
|
|
// literal that an agent executes as-is regardless of mode. Reject BOTH
|
|
// possible hardcoded literals explicitly, not just one, so a fix that
|
|
// flips the hardcoded default to plan-gap-closure (breaking standard mode
|
|
// instead of gap_closure mode) is caught too.
|
|
assert.ok(
|
|
!/--schema\s+plan\)/.test(invocationLine),
|
|
`bash invocation must not hardcode --schema plan — found: ${invocationLine}`
|
|
);
|
|
assert.ok(
|
|
!/--schema\s+plan-gap-closure\)/.test(invocationLine),
|
|
`bash invocation must not hardcode --schema plan-gap-closure — found: ${invocationLine}`
|
|
);
|
|
});
|
|
|
|
test('the --schema argument in the bash invocation IS a shell variable reference', () => {
|
|
// A variable reference means the value is resolved at execution time from
|
|
// whatever the agent has bound it to, not printed once in the template and
|
|
// copy-executed unchanged. Matches --schema "$SCHEMA", --schema $SCHEMA,
|
|
// or --schema "${SCHEMA}".
|
|
const varMatch = /--schema\s+"?\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?"?\)/.exec(invocationLine);
|
|
assert.ok(
|
|
varMatch,
|
|
`bash invocation's --schema argument must be a shell variable (e.g. --schema "$SCHEMA"), not a literal — found: ${invocationLine}`
|
|
);
|
|
});
|
|
|
|
test('the bound variable is actually conditioned on gap_closure mode in the step prose, and both target schema names are named', () => {
|
|
const varMatch = /--schema\s+"?\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?"?\)/.exec(invocationLine);
|
|
assert.ok(varMatch, 'precondition: --schema must reference a variable (see previous test)');
|
|
const varName = varMatch[1];
|
|
|
|
// The SAME variable name the bash block reads must appear in the step's prose
|
|
// (outside the bash block) — otherwise the "binding" is a variable nothing
|
|
// ever explains how to set, which is not meaningfully better than a literal.
|
|
const proseOutsideBash = validateStep.replace(/```bash\r?\n[\s\S]*?```/, '');
|
|
assert.ok(
|
|
proseOutsideBash.includes(`$${varName}`) || proseOutsideBash.includes(`\`$${varName}\``),
|
|
`step prose must explain how $${varName} is set — the bash block references it but nothing binds it`
|
|
);
|
|
|
|
// Both concrete schema names this variable can resolve to must be named
|
|
// somewhere in the step, and gap_closure mode must be the stated condition
|
|
// for choosing between them. Match the plain `plan` schema as a standalone
|
|
// backtick-quoted token (`` `plan` ``), not the bare substring "plan" —
|
|
// a bare-substring check is satisfied incidentally by "verify.plan-structure"
|
|
// a few lines below even if the plain-plan branch were deleted entirely from
|
|
// the prose, which would make this assertion unable to ever fail.
|
|
assert.ok(validateStep.includes('plan-gap-closure'), 'step must name the plan-gap-closure schema');
|
|
assert.ok(
|
|
/`plan`/.test(validateStep),
|
|
'step must name the plain plan schema, as a standalone `plan` token, as the other branch'
|
|
);
|
|
assert.ok(/gap_closure mode/i.test(validateStep), 'step must condition the choice on gap_closure mode by name');
|
|
});
|
|
|
|
test('the plan-structure validation call below (unrelated step) is unaffected', () => {
|
|
// Regression guard for the fix itself: confirm the edit did not touch the
|
|
// sibling verify.plan-structure invocation in the same step.
|
|
assert.ok(
|
|
validateStep.includes('verify.plan-structure "$PLAN_PATH"'),
|
|
'validate_plan step must still invoke verify.plan-structure unchanged'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Cross-file consistency: schema name used by both files matches (#2847) ──
|
|
|
|
describe('#2847: schema name consistency between gsd-planner.md and src/frontmatter.cts', () => {
|
|
test('gsd-planner.md references the exact schema name "plan-gap-closure"', () => {
|
|
const plannerContent = readFile(PLANNER_AGENT_PATH);
|
|
assert.ok(
|
|
plannerContent.includes('plan-gap-closure'),
|
|
'agents/gsd-planner.md must reference the literal schema name "plan-gap-closure" ' +
|
|
'(the exact key registered in FRONTMATTER_SCHEMAS in src/frontmatter.cts) — a ' +
|
|
'mismatched name would fail at runtime with "Unknown schema"'
|
|
);
|
|
});
|
|
});
|
|
|
|
// #2847 review: two describe blocks previously lived here —
|
|
// "planner-gap-closure.md reference is untouched" and "plan-phase.md is
|
|
// deliberately unmodified by this fix" — both deleted. Neither file is
|
|
// touched by this fix, so both assertions were already GREEN at the RED
|
|
// commit (5e5897cd2f17ebf2fc55757bae651bbbeb236289): they pinned untouched
|
|
// files rather than providing regression coverage for anything this change
|
|
// altered. The plan-phase.md one was worse than merely unhelpful — it
|
|
// permanently forbade any FUTURE legitimate `gap_closure` mention in
|
|
// plan-phase.md, a trap for whoever eventually frees up that file's byte
|
|
// budget and has a real reason to add one. The design decision itself (why
|
|
// plan-phase.md is untouched — the ADR-857 PRE_PHASE6 byte ceiling) remains
|
|
// documented in the file-level comment above and in
|
|
// .gsd/bug/fix-2847-gap-closure-frontmatter/10-diagnosis.md; it just isn't
|
|
// asserted as a permanent negative here.
|