fix(#3784): read the hybrid Current Plan: N of M shape, keep zero-padding, and name the accepted shapes on failure (#3791)

* fix(state): read the hybrid "Current Plan: N of M" shape

advancePlanCore derived the value FORMAT from the field NAME, so it
handled the legacy pair (`Current Plan` + `Total Plans in Phase`) and the
compound `Plan: N of M`, but not the hybrid of the two: the legacy field
name carrying a compound value with no Total Plans sibling. `legacyTotal`
is null so the legacy branch fell through, and the compound branch reads
the `Plan` field through a `^Plan:`-anchored pattern that never matches
`Current Plan:`. Both produced NaN against a file whose plan numbers are
plainly readable.

The shape is not exotic. An agent wrote it unprompted into a project's
STATE.md, believing it was the parseable form, and every subsequent run
in that project inherited the failure and worked around it by hand.

Track the field name and the value shape separately (`planSourceField`,
`planRawValue`) so write-back targets whichever field the value came
from. The legacy pair still takes precedence when both fields exist, so
a stray "of N" inside Current Plan cannot override an explicit Total
Plans — covered by a new test.

Also replace the caller's catch-all error. It reported "Cannot parse
Current Plan or Total Plans" for ANY transition failure, and named no
accepted shape, so a reader learned neither what failed nor what to
write. It now distinguishes "no result" from "unreadable plan position"
and lists all three shapes. The existing test asserted the literal
"cannot parse"; it now asserts the message names the shapes, which is
the property that makes it actionable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016QHxbMHTPYbEqAnKTpJPR8

* fix(state): keep zero-padding when advancing a compound plan value

The compound write-back rewrote only the leading half of "N of M", so a
padded value drifted lopsided: "04 of 06" advanced to "5 of 06". Cosmetic
on its own, but a plan line that looks wrong is one the next writer tidies
by hand, and hand-tidying this particular line is what produced the hybrid
shape the previous commit had to teach the parser to read.

Pad the incremented number to the width it was written with. padStart never
truncates, so a value that outgrows its padding widens correctly: 09 of 12
advances to 10 of 12. Unpadded values are untouched — 2 of 6 still advances
to 3 of 6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016QHxbMHTPYbEqAnKTpJPR8

* fix(state): pass a literal field name to the compound write-back

The previous commit passed `planSourceField` — a variable — as the field-name
argument to `stateReplaceField`, which trips the state-write-path drift guard's
`unstripped_content_write` axis (ADR-3408 §8.3(b)). The guard is right to care:
a Title-Case literal cannot collide with a lowercase or snake_case frontmatter
key, so it is safe whatever the content argument is, while a variable could
hold anything and therefore requires its content to be demonstrably
frontmatter-stripped first.

The content argument here IS stripped — `body` is `stripFrontmatter(content)` —
but the guard does a narrow backward scan rather than dataflow tracking, by
design, and the nearest preceding assignment to `body` is another
`stateReplaceField` result. Rather than baseline a bypass or ask a future
reader to re-derive that the invariant holds, dispatch on the discriminator and
pass the literal.

Guard goes from 1 finding to 0; its own 32 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016QHxbMHTPYbEqAnKTpJPR8

* chore(3784): add changeset fragment for #3785

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016QHxbMHTPYbEqAnKTpJPR8

* test(#3784): cover the maintainer's AC1 write-back and AC6 reader-anchoring

Triage published six acceptance criteria; two were only half-covered.

AC1 asks that the hybrid write back to the SAME field with padding preserved.
The existing hybrid test used an unpadded value and asserted only `result.data`,
so it proved the parse but never the write. Now asserts the written content is
`05 of 06` on the original field, and that no separate `Plan:` field appears as
a side effect.

AC6 asks that the shared field reader not be loosened. Reading the hybrid is the
transition's job; `stateExtractField('Plan')` is line-anchored and has 13+
callers, so teaching it to match a name merely ENDING in "Plan" would be the
wrong fix and would silently change what those callers read. This holds by
construction here — the reader is untouched — but nothing locked it in. The new
test fails if anyone later reaches for that shortcut.

Also drops the changeset fragment written against the auto-closed PR number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016QHxbMHTPYbEqAnKTpJPR8

* chore(#3784): add changeset fragment for #3791

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016QHxbMHTPYbEqAnKTpJPR8

* fix(#3784): write the advanced plan back to the field it was read from

Review findings 2-6 on #3791 were one defect seen from several angles: the
read path learned the hybrid `Current Plan: N of M` shape, the write path
did not follow it.

- `bumpLeadingNumber` now owns the increment for all three parse branches.
  Only the leading digits belong to this transition; the padding width and
  everything after it (` of M`, and the `\r` of a CRLF file) are the
  author's text and are preserved. The legacy branch wrote `String(newPlan)`,
  which turned `2 of 99` into `3` and `04` into `5`.
- `mutateCurrentPositionForAdvance` takes the plan field NAME. Its plan arm
  only ever looked for `Plan:`, so on a hybrid file the `## Current Position`
  section was never reached; combined with the body-level write being
  single-shot and bold-preferring, a file carrying the field at both sites
  advanced the header and left the section a plan behind. The parameter
  defaults to `Plan`, so the two callers that pass no plan are unchanged.
- Tests: both-sites-advance (fails without the section arm), legacy
  write-back content assertions (the previous test read only `data` and so
  could not see the lossy write), hybrid boundary at limit-1 and limit+1, a
  CRLF fixture, and an fc property pinning the padding-width contract.

Two characterization tests pinned `**Current Plan:** 02` advancing to `3`.
That dropped padding is the defect #3784 reports, so the expectation is
corrected to `03` rather than the fix being narrowed around it.

* fix(#3784): drop the unreachable advance-plan error branch, sync the doc

Findings 1 and 8 on #3791.

The `!resultData` arm could not fire: the transform callback assigns
`resultData` unconditionally, only runs once STATE.md is known to exist (the
missing-file case returns "STATE.md not found" upstream), and every
`advancePlanCore` return path sets `data`. It was a speculative second
failure mode with a message no caller could receive, and the comment beside
it claimed to distinguish two things that were never two. `!resultData`
stays in the condition as a type guard, which is all it ever was.

`docs/json-errors.md:142` quoted the old error literal verbatim and was the
sole occurrence in the tree; it now quotes the emitted one.

* chore(#3784): describe the write-back fix in the changeset

* fix(#3784): anchor the plan grammar and widen the schema row to match

Review round 3 on #3791: B1, B2, M1, M2, M3, M4 and the planSourceField nit.

B1 — `STATE_FIELD_SCHEMA.current_plan.acceptedShapes` widens to
`['N', 'N of M']`, which is what `src/state-md-schema.cts`'s own comment
instructed this PR to do on merge. `'N/M'` stays undeclared so row 23 keeps a
non-vacuous undeclared candidate to probe. Verified `gen-state-md-docs
--check` exits 0 and `--write` rewrites 0 of 6: the generated artifacts do not
surface this row, so there is nothing stale to regenerate.

B2/M4 — the discriminator was `/of\s+(\d+)/`, unanchored, so a total could be
read out of prose. `Current Plan: 4 — blocked on review of 2 PRs` parsed as
`4 of 2`, took the `currentPlan >= totalPlans` branch and WROTE
`Status: Phase complete — ready for verification` into the user's file. Both
shapes are now anchored at the start and every number comes from a capture
group via `planNumberFrom`, which rejects anything past `Number.MAX_SAFE_INTEGER`
rather than letting `data` and the persisted string disagree. Nothing on this
path calls `parseInt` on a raw field value any more.

The grammar keeps a trailing remainder after the total, because
`Plan: 2 of 5 in current phase` is a real tested shape. The refusal comes from
requiring `of <total>` to follow the leading number immediately, not from
forbidding a suffix.

M1 — `bumpLeadingNumber` is total. It returned its input unchanged when there
were no leading digits, so `+2` reported `advanced: true` while writing the
file untouched.

M2 — both section arms use replacer functions. File-derived text was being
spliced into a `String.replace` replacement string, where `$&` / `` $` `` /
`$'` expand: a value of `04 of 06 $&` spliced part of the document into itself.
`stateReplaceField` already used a function; these now agree with it.

M3 — the section arm targets the name the SECTION carries, and the body write
now writes both spellings, each with its own rendering. Keying off the header's
name left the other name stale in both directions: a legacy header beside a
`Current Plan:` section line, and a `**Plan:**` header beside one.

* fix(#3784): derive the shape error from the schema, widen the test coverage

Review round 3 on #3791: B3, m1, m2, m5 and the two test nits.

B3 — the accepted-shape set had two owners: the parser branches and an English
list hand-written beside them in `state.cts`. Nothing coupled them, so adding a
branch left the message stale and removing one left it advertising a shape that
errors, with no test able to see either. The message is now built from
`STATE_FIELD_SCHEMA.current_plan.acceptedShapes`, and the CLI test walks the
schema instead of restating the list. `Plan: N of M` is still spelled out
explicitly because no schema row owns the body-only `Plan` field —
`buildStateFrontmatter` never reads it into frontmatter, so it has no key to
hang a row on.

m1 — the property drove only the pre-existing `**Plan:**` branch, i.e. not the
branch under review. It now drives both compound spellings and ranges past 99
so the width transition is covered by the property rather than one example. A
second property covers the legacy pair's own preservation contract. Both were
mutation-checked: dropping the padStart turns 9 tests red.

m2 — degenerate boundary fixtures around the threshold (`0 of 0` is
phase-complete, not an error; `0 of 3` advances) plus the shapes the anchored
grammar must refuse, including Arabic-Indic digits.

m5 — `docs/json-errors.md` described rather than quoted the message, since it
is now schema-derived and a verbatim quote would be a third owner.

Nits — the CRLF assertion could not see a `\n` at index 0; the
`!/^Plan:/m` presence proxy is now an identity assertion on the whole
`## Current Position` body.

* fix(#3784): give the section plan write its own flag, and stop narrowing what parses

Review round 4 on #3791: Blockers 1-4, Majors 1-2, Minors 1-2.

B1 — the section fallback was guarded by `!mutated`, and `mutated` is
FUNCTION-wide, already set by the phase/status/lastActivity arms that
`advancePlanCore` always populates. A section spelling the field bold or as a
pipe-table row therefore skipped its fallback because an UNRELATED field had
been refreshed, and stayed a plan behind the header — the split-brain document
this arm exists to prevent. The arm now tracks its own `planWritten`.

Worth recording: the reviewer's fixture does not reproduce. The body-level
status write lands on the section's own `Status:` when the document has no
header `Status:`, so `mutated` is still false by the time the plan arm runs and
the fallback fires. The discriminating shape needs a header `Status:` to absorb
that write AND a bold section plan line. The mechanism was right; the example
was not, and the regression test uses the shape that actually fails.

B2 — `fallbackName` chose one name by ternary. In the legacy shape both values
are populated, so it always chose `Current Plan` and a `**Plan:**` section line
— which base did write — got nothing. Each name is now attempted independently
with its own fallback.

B3 — `PLAN_SHAPE_N` was anchored harder than `PLAN_SHAPE_N_OF_M`, so values base
parsed via `parseInt` began to hard-error: `Total Plans in Phase: 5 phases`,
`Current Plan: 3 (blocked)`. #3784's brief puts normalizing plan numbers beyond
this transition's read/write out of scope, so that narrowing was not licensed.
Both grammars now carry the same trailing tolerance. The prose defect stays
closed by the START anchor, not by forbidding suffixes.

Major 1 — the whole-body `Plan` write is scoped to documents that declare a
`Plan` field, instead of firing unconditionally where `stateReplaceField`'s
first match could be prose outside `## Current Position`.

Major 2 — the error message names both `Plan` spellings the parser accepts; it
previously omitted the sibling-paired form, which is the same
message-disagrees-with-parser drift the derivation exists to close.

B4 — the changeset claimed a guarantee B1 broke; it now describes what ships.

Minors — safe-integer boundary coverage at limit-1/limit/limit+1, and the CRLF
comment states the real mechanism (`stateExtractField`'s `(.+)` stops before the
CR; the trailing group is belt-and-braces, not the primary defence).

All three blocker regression tests verified red against the pre-fix source.

* test(#3784): pin the hybrid shape against #3807's ambiguity refusal

#4028 landed `advance-plan`'s multi-`Phase:` refusal on `next` after this
branch's last run, on the same function. The guard sits above the parse, so
a refused document is never parsed and the shape #3784 adds cannot reach the
mutation — but that is a property of source ordering, so assert it as
behaviour instead.

Fail-first proven, not assumed: with `phaseCandidates.length > 1` disabled,
the ambiguous hybrid document advances its FIRST entry's
`Current Plan: 04 of 06` to `05 of 06` and writes it — #3807's exact defect,
reached through #3784's shape. Both tests go red; both go green with the
guard restored.

The control pins the other direction: an unambiguous hybrid section still
advances, and its zero-padding still survives.

* fix(#3784): advance every spelling from its own text, refuse when they disagree

Round 6 review. B1 and M1 are one defect, so they are one fix.

`advancePlanCore` picked one field to parse from, computed `newPlan`, then
wrote BOTH spellings from that field's numbers. Two symptoms:

  B1  With `Plan` as the parse source, `Current Plan` was re-stamped with
      the number just derived from `Plan`. `Current Plan: 7` beside
      `Plan: 2 of 5` silently became `Current Plan: 3` — a value nothing
      derived for that field, no error, no diagnostic.
  M1  With the legacy pair winning, the `Plan:` line was re-rendered from
      a bare `${newPlan} of ${totalPlans}` built out of the sibling field.
      `Plan: 2 of 9` became `3 of 5`; `Plan: 03 of 05` became `4 of 5`.
      The changeset's claim that padding and everything after it survive
      was true only for whichever field happened to be the parse source.

Now: every spelling is advanced from its own raw text via
`bumpLeadingNumber`, so each keeps its own padding, its own total and its
own trailing annotation. Differing TOTALS are preserved, not reconciled —
`Plan: 2 of 9` beside a `Total Plans in Phase: 5` advances to `3 of 9`.

Differing CURRENT numbers are refused, with `reason:
"ambiguous_plan_position"` and both candidates named. Same posture as
#3807's multi-`Phase:` guard one field over: name the conflict, let the
caller resolve it, never pick. The guard sits immediately after the parse,
BEFORE the phase-complete branch — guarding only the normal advance would
let `Current Plan: 7` beside `Plan: 5 of 5` write a terminal "Phase
complete" into a document whose two spellings never agreed.

A field present but unreadable (`Plan: TBD`) is left exactly as authored.
Refusing the whole document because an unrelated line cannot be read would
be a narrowing #3784 does not license; writing a derived number over it is
the fabrication B1 was filed for.

The `planSourceField`/`planRawValue`/`useCompoundFormat` tracking is gone.
It existed only so the write path could ask which field the value came
from, and the write path no longer asks.

M2. The bare `Plan: N` + `Total Plans in Phase: M` shape is dropped. A
revision of this PR added it; base refused it. It cannot be given the
schema-row + forcing-test coupling the other shapes have, because `Plan`
is body-only and `buildStateFrontmatter` never reads it into frontmatter,
so there is no `current_*` key to hang a row on. Parser, the spelling in
`advancePlanShapeError`, and the lockstep test move together — the
invariant is the lockstep, not the length of the list.

N1. The whitespace narrowing (`5phases` no longer parses where `parseInt`
read 5) is documented in the changeset beside the other deliberate
narrowings, rather than loosened. Loosening restores the half-parse this
change exists to remove.

Tests: eight new cases plus a property that crosses the two spellings with
agreeing and disagreeing numbers — the review noted the existing
properties never did. Fail-first proven: restoring the old write path
reddens seven of the eight, both new property arms, and two pre-existing
padding tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H3eK225hgcnEDZsnmtaP1U

* fix(#3784): report Current Plan as updated only when it was written

The write became conditional in the previous commit — a `Current Plan:`
that is present but unreadable is left as authored — but the `updated`
push stayed unconditional, so `transitionCore` reported a field it had not
touched. `reconcileReportedFields` would have caught it against the
persisted bytes at the `state.cts` caller, but `transitionCore`'s own
`updated` is consumed directly (milestone-lock, the transition tests) and
has to be true on its own.

Covers the mirror of the unreadable-spelling case: `Current Plan: TBD`
beside a readable `Plan: 2 of 5`, where `Plan` is the parse source and the
legacy field is the one that cannot advance. Fail-first proven.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H3eK225hgcnEDZsnmtaP1U

* test(#3784): account for the new refusal in the output({error}) census

`tests/io.test.cjs`' A3 census asserts the exact population of
`output({error})` call sites in `src/`, per module. The
`ambiguous_plan_position` refusal added a 27th to `state.cts`, so the
census went red at 26/65.

Updated the way #3807 updated it when it added the ambiguous-POSITION
error one line above: bump the count and name the addition inline, so the
next person reads why the number is what it is. The alarm did its job —
it is the only gate that noticed a new user-visible error path had been
introduced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H3eK225hgcnEDZsnmtaP1U

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Adnan
2026-09-01 20:03:20 +01:00
committed by GitHub
parent 7c116b1c17
commit bdfc62889b
9 changed files with 1241 additions and 59 deletions

View File

@@ -97,4 +97,71 @@ describe('#3807: advance-plan refuses an ambiguous multi-entry Current Position'
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
assert.match(after, /Plan: 4 of 8/, 'the plan counter advanced');
});
// ───────────────────────────────────────────────────────────────────────────
// #3784 x #3807 interaction. #3784 taught advancePlanCore a third value
// shape — the hybrid `Current Plan: N of M` (legacy field name, compound
// value, no `Total Plans in Phase` sibling). Both changes land on the same
// function, and the guard sits ABOVE the parse, so a document the guard
// refuses is never parsed at all. That ordering is the whole answer to
// "does the widened grammar bypass the refusal" — but ordering is a
// property of the source, and these two assert it as behaviour.
//
// Fail-first proven, not assumed: with the `phaseCandidates.length > 1`
// refusal disabled, the multi-entry case below advances the FIRST entry's
// `Current Plan: 04 of 06` to `05 of 06` and writes it — #3807's exact
// defect, reached through the shape #3784 added.
// ───────────────────────────────────────────────────────────────────────────
const HYBRID_ENTRY = (phase, plan) => [
`Phase: ${phase}`,
`Current Plan: ${plan}`,
'Status: In progress',
'Last activity: 2026-08-24 — working',
'',
];
test('#3784 x #3807: the hybrid `Current Plan: N of M` shape does not bypass the refusal', (t) => {
const tmpDir = createTempProject('gsd-3807-hybrid-amb-');
t.after(() => cleanup(tmpDir));
writeState(tmpDir, [
'## Current Position',
'',
...HYBRID_ENTRY('03.1 of 8 (some-phase)', '04 of 06'),
...HYBRID_ENTRY('04 of 15 (other-phase)', '04 of 15'),
].join('\n'));
const before = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
const r = runAdvance(tmpDir);
const out = JSON.parse(r.output);
assert.equal(
out.reason,
'ambiguous_position_phase',
`#3807's refusal must fire on the hybrid shape too, not #3784's parse; got ${r.output}`,
);
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
assert.equal(after, before, 'refusing must leave STATE.md byte-identical on the hybrid shape');
assert.ok(
!/Current Plan: 05 of 06/.test(after),
"the first entry's hybrid plan counter must NOT advance",
);
});
test('#3784 x #3807 control: a single-entry hybrid section still advances, padding intact', (t) => {
const tmpDir = createTempProject('gsd-3807-hybrid-ctl-');
t.after(() => cleanup(tmpDir));
writeState(tmpDir, [
'## Current Position',
'',
...HYBRID_ENTRY('03.1 of 8 (some-phase)', '04 of 06'),
].join('\n'));
const r = runAdvance(tmpDir);
const out = JSON.parse(r.output);
assert.equal(out.advanced, true, `the hybrid shape still advances when unambiguous; got ${r.output}`);
assert.equal(out.current_plan, 5, '#3784: the hybrid value supplies the plan number');
assert.equal(out.total_plans, 6, '#3784: the hybrid value supplies the total, with no sibling field');
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
assert.match(after, /Current Plan: 05 of 06/, '#3784: zero-padding survives the advance');
});
});

View File

@@ -841,11 +841,11 @@ describe('#3912 A3-A5: output({error}) records DEGRADED — shape-exhaustive plu
perFile,
{
'commands.cts': 5, 'frontmatter.cts': 7, 'gsd2-import.cts': 2, 'phase.cts': 4,
'roadmap.cts': 3, 'state.cts': 26, 'template.cts': 3, 'verify.cts': 8, 'workstream.cts': 7, // +1 #3807: advance-plan's ambiguous-position error
'roadmap.cts': 3, 'state.cts': 27, 'template.cts': 3, 'verify.cts': 8, 'workstream.cts': 7, // +1 #3807: advance-plan's ambiguous-position error; +1 #3784: advance-plan's ambiguous-PLAN-position error (two plan spellings, different numbers)
},
`per-file output({error}) census drifted: ${JSON.stringify(perFile)}`,
);
assert.strictEqual(total, 65, `enumerated output({error}) population drifted from the measured 65 (64 + #3807's ambiguous-position error): got ${total}`);
assert.strictEqual(total, 66, `enumerated output({error}) population drifted from the measured 66 (64 + #3807's ambiguous-position error + #3784's ambiguous-plan-position error): got ${total}`);
});
});

View File

@@ -578,7 +578,11 @@ describe('ADR-1769 Phase 2: advancePlan transition', () => {
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '3');
// #3784: was '3'. The dropped zero-padding this used to pin is the defect
// the issue reports, not behaviour worth preserving — a fixture written
// "02" must not come back "3". The characterization is updated rather than
// worked around, because the old value WAS the bug.
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '03');
assert.strictEqual(result.data && result.data.advanced, true);
assert.strictEqual(result.data && result.data.current_plan, 3);
assert.strictEqual(result.data && result.data.total_plans, 5);
@@ -607,6 +611,488 @@ describe('ADR-1769 Phase 2: advancePlan transition', () => {
assert.deepStrictEqual(result.updated, []);
});
// Hybrid shape: the legacy field NAME carrying the compound VALUE, with no
// `Total Plans in Phase` sibling. Neither documented branch handled it —
// `legacyTotal` is null so the legacy branch fell through, and the compound
// branch reads the `Plan` field through a `^Plan:` line-anchored pattern
// that never matches `Current Plan:`. Both produced NaN, and the caller
// reported a parse failure against a file whose plan numbers are plainly
// readable.
//
// Not hypothetical: an agent wrote this exact shape unprompted, believing
// it was the parseable form, and every later run inherited it.
test('hybrid format: "Current Plan: 4 of 6" with no Total Plans sibling', () => {
const input = [
'# Project State',
'',
'**Status:** Executing Phase 7',
'**Last Activity:** 2026-06-26',
'',
'## Current Position',
'',
'Current Plan: 4 of 6',
'Status: Executing Phase 7',
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
assert.strictEqual(result.data && result.data.error, undefined);
assert.strictEqual(result.data && result.data.advanced, true);
assert.strictEqual(result.data && result.data.current_plan, 5);
assert.strictEqual(result.data && result.data.total_plans, 6);
});
// AC1: the hybrid must write back to the SAME field with padding preserved,
// not merely report the right numbers in `data`.
test('hybrid format: writes back to Current Plan with padding preserved', () => {
const input = [
'# Project State',
'',
'**Status:** Executing Phase 7',
'**Last Activity:** 2026-06-26',
'',
'## Current Position',
'',
'Current Plan: 04 of 06',
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '05 of 06');
// Identity, not a presence proxy: assert the whole section body, so a
// spurious extra field or a dropped line is visible rather than merely
// "no line starting with Plan:".
const section = result.content.slice(result.content.indexOf('## Current Position'));
assert.strictEqual(section.trimEnd(), ['## Current Position', '', 'Current Plan: 05 of 06'].join('\n'));
});
test('hybrid format: phase-complete branch still fires on the last plan', () => {
const input = [
'# Project State',
'',
'**Status:** Executing Phase 7',
'**Last Activity:** 2026-06-26',
'',
'## Current Position',
'',
'Current Plan: 6 of 6',
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
assert.strictEqual(result.data && result.data.advanced, false);
assert.strictEqual(result.data && result.data.reason, 'last_plan');
});
test('compound format preserves zero-padding on both halves', () => {
const input = [
'# Project State',
'',
'**Plan:** 04 of 06',
'**Status:** Executing Phase 7',
'**Last Activity:** 2026-06-26',
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
assert.strictEqual(stateExtractField(result.content, 'Plan'), '05 of 06');
});
test('padding widens rather than truncates when the plan number grows', () => {
const input = [
'# Project State',
'',
'**Plan:** 09 of 12',
'**Status:** Executing Phase 7',
'**Last Activity:** 2026-06-26',
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
assert.strictEqual(stateExtractField(result.content, 'Plan'), '10 of 12');
});
test('unpadded compound stays unpadded', () => {
const input = [
'# Project State',
'',
'**Plan:** 2 of 6',
'**Status:** Executing Phase 7',
'**Last Activity:** 2026-06-26',
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
assert.strictEqual(stateExtractField(result.content, 'Plan'), '3 of 6');
});
// AC6: the shared field reader must NOT be loosened to make the hybrid work.
// Reading the hybrid is the transition's job; `stateExtractField('Plan')` is
// line-anchored (`^Plan:`) and has 13+ callers, so teaching it to match a
// field name that merely ENDS in "Plan" would be the wrong fix and would
// silently change what those callers read. This test fails if anyone tries it.
test('shared reader stays anchored: "Plan" does not match "Current Plan:"', () => {
const content = [
'# Project State',
'',
'## Current Position',
'',
'Current Plan: 04 of 06',
'',
].join('\n');
assert.strictEqual(stateExtractField(content, 'Plan'), null);
assert.strictEqual(stateExtractField(content, 'Current Plan'), '04 of 06');
});
// The legacy pair must keep winning when both are present: a stray "of N"
// inside the Current Plan value must not override an explicit Total Plans.
test('legacy pair still takes precedence over an "of N" in Current Plan', () => {
const input = [
'# Project State',
'',
'**Current Plan:** 2 of 99',
'**Total Plans in Phase:** 5',
'**Status:** Executing Phase 3',
'**Last Activity:** 2026-06-26',
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
assert.strictEqual(result.data && result.data.advanced, true);
assert.strictEqual(result.data && result.data.total_plans, 5);
// Assert the WRITE, not just the parse. Reading `data` alone cannot see a
// lossy write-back, and a regression test that cannot observe the
// regression is not coverage. The legacy branch used to write
// `String(newPlan)`, which turned "2 of 99" into a bare "3" — silently
// destroying the reader's own text on a branch nobody was looking at.
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '3 of 99');
assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '5');
});
test('legacy pair preserves zero-padding on write-back', () => {
const input = [
'# Project State',
'',
'**Current Plan:** 04',
'**Total Plans in Phase:** 06',
'**Status:** Executing Phase 3',
'**Last Activity:** 2026-06-26',
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '05');
});
// Findings 2+3 are one defect seen twice: the body-level write is single-shot
// and bold-preferring, so on a file carrying the field at BOTH the bold header
// and the `## Current Position` line it updates the header only — and
// `mutateCurrentPositionForAdvance` could not pick up the slack because its
// plan arm only ever looked for `Plan:`, never `Current Plan:`. The earlier
// hybrid write-back test passed only because its fixture was single-site.
test('hybrid format: header and Current Position both advance, no drift', () => {
const input = [
'# Project State',
'',
'**Current Plan:** 04 of 06',
'**Status:** Executing Phase 7',
'**Last Activity:** 2026-06-26',
'',
'## Current Position',
'',
'Current Plan: 04 of 06',
'Status: Executing Phase 7',
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
const advanced = result.content.match(/05 of 06/g) || [];
assert.strictEqual(advanced.length, 2, 'both sites must advance');
assert.ok(!/04 of 06/.test(result.content), 'no site may be left behind');
});
// Review round 4, Blocker 1. The section fallback was guarded by the
// FUNCTION-wide `mutated`, which the status/lastActivity arms had already set.
// The discriminating shape needs BOTH a header `Status:` (to absorb the
// body-level status write, so the section's own status is still a template
// default when the section arm runs) AND a bold section plan line (so the
// plain-line arm misses and only the fallback can write it). Without the
// header Status this passes even on the broken build.
test('B1: a bold section plan line advances when an unrelated field was also refreshed', () => {
const input = [
'# Project State',
'',
'**Current Plan:** 04 of 06',
'**Status:** Ready to plan',
'',
'## Current Position',
'',
'**Current Plan:** 01 of 06',
'Status: Ready to plan',
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
const section = result.content.slice(result.content.indexOf('## Current Position'));
assert.match(section, /\*\*Current Plan:\*\* 05 of 06/, 'the bold section line must advance');
assert.ok(!/01 of 06/.test(result.content), 'no site may be left behind');
});
// Review round 4, Blocker 2. In the legacy shape BOTH plan values are
// populated, so a fallback that picked one name by ternary always chose
// `Current Plan` and never wrote a `**Plan:**` section line — which base did
// write. The header `**Plan:**` absorbs the body-level write, so only the
// section arm can advance the section copy.
test('B2: a bold **Plan:** section line advances in the legacy pair shape', () => {
const input = [
'# Project State',
'',
'**Current Plan:** 3',
'**Total Plans in Phase:** 5',
'**Plan:** 3 of 5',
'**Status:** Ready to plan',
'',
'## Current Position',
'',
'**Plan:** 3 of 5',
'Status: Ready to plan',
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
const section = result.content.slice(result.content.indexOf('## Current Position'));
assert.match(section, /\*\*Plan:\*\* 4 of 5/, 'the section Plan line must advance');
assert.ok(!/3 of 5/.test(result.content), 'no site may be left behind');
});
// Review round 4, Blocker 3. `PLAN_SHAPE_N` was anchored harder than
// `PLAN_SHAPE_N_OF_M`, so annotated values base parsed via `parseInt` began to
// hard-error. Narrowing what the transition ACCEPTS is out of scope for #3784.
test('B3: annotated legacy values still parse, and keep their annotation', () => {
const drive = (plan, total) => transitionCore([
'# Project State', '',
`**Current Plan:** ${plan}`,
`**Total Plans in Phase:** ${total}`,
'**Status:** Executing', '',
].join('\n'), { kind: 'advancePlan' }, deps);
const annotatedTotal = drive('3', '5 phases');
assert.strictEqual(annotatedTotal.data.advanced, true, '"5 phases" must still supply a total');
assert.strictEqual(annotatedTotal.data.total_plans, 5);
const annotatedPlan = drive('3 (blocked)', '5');
assert.strictEqual(annotatedPlan.data.advanced, true, '"3 (blocked)" must still advance');
assert.strictEqual(
stateExtractField(annotatedPlan.content, 'Current Plan'), '4 (blocked)',
'the annotation is the author\'s text and survives the advance',
);
// The prose case stays refused: the START anchor is what closes it, not the
// absence of a suffix.
const prose = transitionCore([
'# Project State', '', '**Current Plan:** 4 — blocked on review of 2 PRs',
'**Status:** Executing', '',
].join('\n'), { kind: 'advancePlan' }, deps);
assert.strictEqual(prose.data.error, true, 'a total must never be read out of prose');
});
// Minor 1: the Number.isSafeInteger bound this PR introduces.
test('boundary: the safe-integer limit', () => {
const drive = (v) => transitionCore([
'# Project State', '', `**Current Plan:** ${v}`, '**Status:** Executing', '',
].join('\n'), { kind: 'advancePlan' }, deps).data;
const MAX = Number.MAX_SAFE_INTEGER; // 9007199254740991
assert.strictEqual(drive(`${MAX - 1} of ${MAX}`).advanced, true, 'limit-1 advances');
assert.strictEqual(drive(`${MAX} of ${MAX}`).reason, 'last_plan', 'limit itself is readable');
assert.strictEqual(drive(`${MAX} of 9007199254740992`).error, true, 'limit+1 is refused, not rounded');
});
// Boundary coverage (RULESET.TESTS.boundary-coverage) around the
// `currentPlan >= totalPlans` limit, on the newly readable hybrid shape.
// limit itself ("6 of 6") is covered by the phase-complete test above.
test('hybrid boundary: limit-1 advances', () => {
const input = [
'# Project State',
'',
'## Current Position',
'',
'Current Plan: 5 of 6',
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
assert.strictEqual(result.data && result.data.advanced, true);
assert.strictEqual(result.data && result.data.current_plan, 6);
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '6 of 6');
});
test('hybrid boundary: limit+1 takes the phase-complete branch', () => {
const input = [
'# Project State',
'',
'## Current Position',
'',
'Current Plan: 7 of 6',
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
// Mirrors the compound branch's existing `>= totalPlans` semantics — an
// over-limit value is past the end, not a new advance.
assert.strictEqual(result.data && result.data.advanced, false);
assert.strictEqual(result.data && result.data.reason, 'last_plan');
});
// CRLF: regexes matching only \n are a recurring defect class here, and the
// write path's `(.*)` capture eats a trailing \r. A file that arrives CRLF
// must not leave with one line silently converted to LF.
test('hybrid format: CRLF line endings survive the write-back', () => {
const input = [
'# Project State',
'',
'## Current Position',
'',
'Current Plan: 04 of 06',
'Status: Executing Phase 7',
'',
].join('\r\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
assert.ok(/Current Plan: 05 of 06\r\n/.test(result.content),
'the advanced line must keep its CRLF terminator');
assert.ok(!/(^|[^\r])\n/.test(result.content), 'no line may be downgraded to bare LF');
});
// RULESET.TESTS.property-based-testing: this is a parsing/transformation with
// a format-preserving contract, so the contract gets a property, not just
// examples. Contract: advancing rewrites ONLY the leading integer, pads it to
// at least the original digit width, and leaves the rest of the value byte-
// identical.
// Drives BOTH compound spellings — `Plan:` and the hybrid `Current Plan:`
// this PR adds — because a property that only exercises the pre-existing
// branch says nothing about the branch under review. `n` ranges past 99 so
// the 99 -> 100 width transition is covered by the property rather than by a
// single example.
test('property: advancing preserves padding width and the " of M" remainder', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 150 }),
fc.integer({ min: 1, max: 4 }),
fc.integer({ min: 1, max: 4 }),
fc.constantFrom('Plan', 'Current Plan'),
(n, planWidth, totalWidth, fieldName) => {
const total = n + 2; // strictly greater, so the advance branch is taken
const planStr = String(n).padStart(planWidth, '0');
const totalStr = String(total).padStart(totalWidth, '0');
const input = [
'# Project State',
'',
`**${fieldName}:** ${planStr} of ${totalStr}`,
'**Status:** Executing Phase 7',
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
const expected = `${String(n + 1).padStart(planStr.length, '0')} of ${totalStr}`;
assert.strictEqual(stateExtractField(result.content, fieldName), expected);
},
),
{ numRuns: 200 },
);
});
// The legacy pair's own format-preserving contract: the ` of M` remainder and
// the padding survive there too, and the sibling supplies the total.
test('property: the legacy pair preserves the written Current Plan value shape', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 150 }),
fc.integer({ min: 1, max: 4 }),
fc.option(fc.integer({ min: 1, max: 999 }), { nil: null }),
(n, planWidth, inlineTotal) => {
const total = n + 2;
const planStr = String(n).padStart(planWidth, '0');
const written = inlineTotal === null ? planStr : `${planStr} of ${inlineTotal}`;
const input = [
'# Project State',
'',
`**Current Plan:** ${written}`,
`**Total Plans in Phase:** ${total}`,
'**Status:** Executing Phase 7',
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
const bumped = String(n + 1).padStart(planStr.length, '0');
const expected = inlineTotal === null ? bumped : `${bumped} of ${inlineTotal}`;
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), expected);
},
),
{ numRuns: 200 },
);
});
// #3791 review round 6: the two properties above each exercise ONE spelling,
// so neither could see a document carrying both — which is where B1 and M1
// both lived. This one crosses the two contracts, with agreeing and
// disagreeing numbers, and asserts the per-spelling contract on each half.
test('property: two spellings — each keeps its own shape when they agree, and neither moves when they do not', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 150 }),
fc.integer({ min: 1, max: 4 }),
fc.integer({ min: 1, max: 4 }),
// The `Plan:` line's OWN total, independent of the sibling field.
fc.integer({ min: 200, max: 400 }),
// 0 = the two spellings agree; anything else is the offset that makes
// them disagree.
fc.integer({ min: 0, max: 9 }),
(n, legacyWidth, planWidth, planOwnTotal, disagreeBy) => {
const siblingTotal = n + 2; // strictly greater, so the advance branch is taken
const legacyStr = String(n).padStart(legacyWidth, '0');
const planN = n + disagreeBy;
const planStr = String(planN).padStart(planWidth, '0');
const input = [
'# Project State',
'',
`**Current Plan:** ${legacyStr}`,
`**Total Plans in Phase:** ${siblingTotal}`,
'**Status:** Executing Phase 7',
'',
'## Current Position',
'',
`Plan: ${planStr} of ${planOwnTotal}`,
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
if (disagreeBy !== 0) {
// Refused, and nothing written — not one field, not the status.
assert.strictEqual(result.data && result.data.error, true);
assert.strictEqual(result.data && result.data.reason, 'ambiguous_plan_position');
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), legacyStr);
assert.strictEqual(stateExtractField(result.content, 'Plan'), `${planStr} of ${planOwnTotal}`);
return;
}
// They agree: both advance, and each keeps its OWN written shape —
// the legacy field its padding and bare form, the `Plan:` line its
// padding AND its own total, which is never the sibling's.
assert.strictEqual(result.data && result.data.advanced, true);
assert.strictEqual(
stateExtractField(result.content, 'Current Plan'),
String(n + 1).padStart(legacyStr.length, '0'),
);
assert.strictEqual(
stateExtractField(result.content, 'Plan'),
`${String(n + 1).padStart(planStr.length, '0')} of ${planOwnTotal}`,
);
},
),
{ numRuns: 300 },
);
});
// m2: the degenerate end of the totalPlans threshold, and the shapes the
// anchored grammar must refuse. `0 of 0` is `currentPlan >= totalPlans`, so
// it is phase-complete rather than an error — pinned so the grammar
// tightening cannot silently reclassify it.
test('boundary: degenerate and malformed plan values', () => {
const drive = (value) => {
const input = ['# Project State', '', `**Current Plan:** ${value}`, '**Status:** Executing', ''].join('\n');
return transitionCore(input, { kind: 'advancePlan' }, deps).data;
};
assert.strictEqual(drive('0 of 0').reason, 'last_plan', '"0 of 0" is past the end, not an error');
assert.strictEqual(drive('0 of 3').advanced, true, '"0 of 3" advances to 1');
for (const bad of ['-1 of 6', '+2 of 6', '\u0663 of \u0665', '3 of', 'of 5', 'x of 5']) {
assert.strictEqual(drive(bad).error, true, `${JSON.stringify(bad)} must be refused`);
}
});
test('compound format: "Plan: 2 of 6" preserves compound shape', () => {
const input = [
'# Project State',
@@ -646,6 +1132,201 @@ describe('ADR-1769 Phase 2: advancePlan transition', () => {
// pins today's reality, and it is EXPECTED to go RED the moment the parser
// changes underneath it. That is the forcing function working as designed
// (§8.8 "checked, not generated"), not a broken test.
describe('#3791 review round 6 (B1/M1): every spelling advances from its own text', () => {
const deps = { clock: fixedClock };
const advance = (lines) => transitionCore(lines.join('\n'), { kind: 'advancePlan' }, deps);
// ─── M1: a field keeps its OWN total, padding and annotation ───────────────
//
// The legacy pair wins precedence, but that only decides which numbers the
// ADVANCE is computed from. It does not license re-rendering the `Plan:` line
// from the legacy pair's numbers, which is what the previous revision did:
// `planDisplayValue` fell back to a bare `${newPlan} of ${totalPlans}` built
// from the sibling field.
test('the Plan line keeps its own total when it differs from the sibling field', () => {
const result = advance([
'# Project State',
'',
'**Current Plan:** 2',
'**Total Plans in Phase:** 5',
'**Status:** Executing',
'',
'## Current Position',
'',
'Plan: 2 of 9',
'Status: Executing',
'',
]);
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '3');
// Was '3 of 5' — the sibling's total silently overwrote the line's own.
assert.strictEqual(stateExtractField(result.content, 'Plan'), '3 of 9');
});
test('the Plan line keeps its own zero-padding when the legacy pair supplies the numbers', () => {
const result = advance([
'# Project State',
'',
'**Current Plan:** 03',
'**Total Plans in Phase:** 05',
'**Status:** Executing',
'',
'## Current Position',
'',
'Plan: 03 of 05',
'Status: Executing',
'',
]);
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '04');
// Was '4 of 5' — the changeset's "the zero-padding width and everything
// after it survive" was true only for the parse-source field.
assert.strictEqual(stateExtractField(result.content, 'Plan'), '04 of 05');
});
test('a trailing annotation on the Plan line survives an advance driven by the legacy pair', () => {
const result = advance([
'# Project State',
'',
'**Current Plan:** 2',
'**Total Plans in Phase:** 5',
'',
'## Current Position',
'',
'Plan: 2 of 5 in current phase',
'',
]);
assert.strictEqual(stateExtractField(result.content, 'Plan'), '3 of 5 in current phase');
});
// ─── B1: two spellings, different numbers — refuse, never fabricate ────────
test('refuses when Current Plan and Plan carry different numbers', () => {
const result = advance([
'# Project State',
'',
'**Current Plan:** 7',
'**Status:** Executing',
'',
'## Current Position',
'',
'Plan: 2 of 5',
'',
]);
assert.strictEqual(result.data && result.data.error, true);
assert.strictEqual(result.data && result.data.reason, 'ambiguous_plan_position');
assert.deepStrictEqual(result.data && result.data.plan_candidates,
['Current Plan: 7', 'Plan: 2 of 5']);
// Nothing is written. The previous revision advanced `Plan` to `3 of 5` and
// stamped `Current Plan: 3` — a number with no relationship to the 7 the
// author wrote.
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '7');
assert.strictEqual(stateExtractField(result.content, 'Plan'), '2 of 5');
});
test('the disagreement refusal runs BEFORE the phase-complete branch', () => {
// `Plan: 5 of 5` alone would advance=false / last_plan and write a terminal
// "Phase complete — ready for verification". Guarding only the normal
// advance path would let it do that to a document whose two spellings never
// agreed on where execution was.
const result = advance([
'# Project State',
'',
'**Current Plan:** 7',
'**Status:** Executing',
'',
'## Current Position',
'',
'Plan: 5 of 5',
'',
]);
assert.strictEqual(result.data && result.data.error, true);
assert.strictEqual(result.data && result.data.reason, 'ambiguous_plan_position');
assert.ok(!/Phase complete/.test(result.content),
'a disagreeing document must not be marked phase-complete');
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '7');
});
test('agreeing spellings are not a disagreement, whatever their totals say', () => {
const result = advance([
'# Project State',
'',
'**Current Plan:** 2',
'**Total Plans in Phase:** 5',
'',
'## Current Position',
'',
'Plan: 2 of 9',
'',
]);
assert.strictEqual(result.data && result.data.error, undefined);
assert.strictEqual(result.data && result.data.advanced, true);
});
// ─── An unreadable sibling is left alone, not overwritten ──────────────────
test('a Plan line with no readable number is left exactly as authored', () => {
const result = advance([
'# Project State',
'',
'**Current Plan:** 2',
'**Total Plans in Phase:** 5',
'',
'## Current Position',
'',
'Plan: TBD',
'',
]);
// The advance still happens — refusing the whole document because an
// unrelated line is unreadable would be a narrowing #3784 does not license.
assert.strictEqual(result.data && result.data.advanced, true);
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '3');
// ...and the unreadable line is untouched rather than fabricated over.
assert.strictEqual(stateExtractField(result.content, 'Plan'), 'TBD');
});
test('a Current Plan with no readable number is left alone, and not reported as updated', () => {
// The mirror of the case above: `Plan` is the parse source and the legacy
// field is the unreadable one. It exercises the other arm of the same skip,
// and it is the fixture where an unconditional `updated.push('Current Plan')`
// would claim a write that never happened.
const result = advance([
'# Project State',
'',
'**Current Plan:** TBD',
'**Status:** Executing',
'',
'## Current Position',
'',
'Plan: 2 of 5',
'',
]);
assert.strictEqual(result.data && result.data.advanced, true);
assert.strictEqual(stateExtractField(result.content, 'Plan'), '3 of 5');
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), 'TBD');
assert.ok(!result.updated.includes('Current Plan'),
'must not report a field it did not write');
assert.ok(result.updated.includes('Status'), 'the fields it did write are still reported');
});
// ─── M2: the bare `Plan: N` + sibling shape is not accepted ────────────────
test('a bare Plan: N with a Total sibling and no Current Plan is refused', () => {
const result = advance([
'# Project State',
'',
'**Plan:** 2',
'**Total Plans in Phase:** 5',
'',
]);
// Base refused this (its `else if (planField)` arm had no `of M` match and
// errored via NaN). A revision of this PR accepted it; #3791 review round 6
// (M2) is that it cannot be given the schema-row + forcing-test coupling
// the other shapes have, because `Plan` is body-only.
assert.strictEqual(result.data && result.data.error, true);
assert.strictEqual(result.data && result.data.reason, undefined);
});
});
describe('#3873 phase-3 rows 23/24/25: parser accepts exactly the schema-declared shapes', () => {
const deps = { clock: fixedClock };
@@ -716,17 +1397,34 @@ describe('#3873 phase-3 rows 23/24/25: parser accepts exactly the schema-declare
// The worked case (#3784's three spellings): current_plan specifically.
test('planNofMShapesAreExactlyTheDeclaredSet', () => {
assert.deepStrictEqual(Array.from(STATE_FIELD_SCHEMA.current_plan.acceptedShapes), ['N']);
// #3791 widened this row, exactly as the schema's own comment instructed.
assert.deepStrictEqual(Array.from(STATE_FIELD_SCHEMA.current_plan.acceptedShapes), ['N', 'N of M']);
// Declared shape parses.
assert.strictEqual(driveCurrentPlanShape('3', { withTotalSibling: true }), true, '"N" (paired with Total Plans in Phase) should parse');
// The hybrid shape is NOT declared today (#3784/#3791 boundary) and does
// NOT parse standalone in the Current Plan field.
assert.strictEqual(driveCurrentPlanShape('3 of 5'), false, '"N of M" standalone in Current Plan should NOT parse today');
// The hybrid shape is now declared AND parses standalone — #3784.
assert.strictEqual(driveCurrentPlanShape('3 of 5'), true, '"N of M" standalone in Current Plan should parse');
// A fourth, never-declared spelling fails rather than quietly joining.
assert.strictEqual(driveCurrentPlanShape('3/5'), false, '"N/M" should NOT parse — it has never been declared');
// Declaring "N of M" is a claim about an ANCHORED grammar, not about the
// substring "of". Prose that merely contains it must still be refused —
// otherwise `4 — blocked on review of 2 PRs` reads as "4 of 2", which is
// `currentPlan >= totalPlans` and WRITES a terminal phase-complete status.
for (const prose of [
'4 — blocked on review of 2 PRs',
'3 (waiting for refactor of 1 module)',
'2roof 5',
'+2 of 6',
'-1 of 6',
]) {
assert.strictEqual(
driveCurrentPlanShape(prose), false,
`${JSON.stringify(prose)} must NOT parse — the declared shape is anchored`,
);
}
});
});
@@ -754,8 +1452,8 @@ describe('ADR-1769 Phase 2: advancePlan with frontmatter (#1255 pattern — code
'',
].join('\n');
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
// Body Current Plan must advance to 3.
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '3');
// Body Current Plan must advance to 3, keeping the written width (#3784).
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '03');
// Body Status must be updated (not the YAML status key).
const bodyStatus = stateExtractField(result.content, 'Status');
assert.ok(

View File

@@ -1912,7 +1912,40 @@ describe('cmdStateAdvancePlan (state advance-plan)', () => {
const output = JSON.parse(result.output);
assert.ok(output.error !== undefined, 'output should have error field');
assert.ok(output.error.toLowerCase().includes('cannot parse'), 'error should mention Cannot parse');
// Assert on what makes the message actionable, not on one literal phrase:
// it must say the plan position could not be read AND name the shapes that
// would work. The previous assertion only checked for "cannot parse", which
// a message can satisfy while leaving the reader no idea what to write.
assert.ok(
/cannot read the plan position/i.test(output.error),
`error should say the plan position could not be read; got: ${output.error}`,
);
// Coupling, not transcription: the message is DERIVED from
// `STATE_FIELD_SCHEMA.current_plan.acceptedShapes`, so this walks the
// schema rather than restating a list beside it. Widening the schema
// without widening the message (or vice versa) goes red here.
const { STATE_FIELD_SCHEMA } = require('../gsd-core/bin/lib/state-md-schema.cjs');
const declared = STATE_FIELD_SCHEMA['current_plan'].acceptedShapes;
assert.ok(declared.length > 0, 'schema must declare at least one shape, else this assertion is vacuous');
for (const shape of declared) {
const spelling = shape === 'N'
? 'Total Plans in Phase'
: `Current Plan: ${shape}`;
assert.ok(
output.error.includes(spelling),
`error should name declared shape ${JSON.stringify(shape)} as ${JSON.stringify(spelling)}; got: ${output.error}`,
);
}
// The body-only `Plan` field has no schema row (buildStateFrontmatter never
// reads it into frontmatter), so it is named explicitly.
assert.ok(output.error.includes('`Plan: N of M`'),
`error should name \`Plan: N of M\`; got: ${output.error}`);
// ...and must NOT advertise a shape the parser refuses (#3791 review round
// 6, M2). `Plan: N` paired with a `Total Plans in Phase: M` sibling and no
// `Current Plan` is not an accepted shape; a message naming it would send
// the reader to write a STATE.md this command still cannot read.
assert.ok(!output.error.includes('`Plan: N` with'),
`error must not advertise the unaccepted bare-Plan+sibling shape; got: ${output.error}`);
});
test('advances plan in compound "Plan: X of Y" format', () => {