* fix(#3951): two lint rules that could not reach the code they govern B6 names two widenings. Measuring them first turned up a defect the criterion did not know about, and refuted the reason it gave for one of them. 1. no-adhoc-markdown-parsing self-gates on its own filename. Lines 107-110 short-circuit create() to {} unless the path matches /(?:^|\/)src\/[^/]+\.cts$/. B6 says to widen the files: glob in eslint.config.mjs - but doing only that ships an INERT rule, because the gate still returns {} for every new path. Both halves have to change, and the gate is the load-bearing one. That same regex hides a live hole: [^/]+ is FLAT-ONLY, so it requires the file to sit directly in src/. The registered glob is src/**/*.cts, which includes subdirectories. 28 .cts files - health-diagnostic-rules/ (10), installer-migrations/ (11), observability/ (3), host-integration-adapters/ (2), vendor/ (2) - are inside the registered glob and silently skipped. Measured with the gate neutralized: 0 violations there today. The hole is hiding nothing right now, and is fixed anyway, because "no violations today" is not a property that keeps holding. The fix is not invented: require-subprocess-timeout.cjs:196 already carries the correct form of this guard, /(?:^|\/)src\/.*\.cts$/ with .*, one directory over. Checked the other 21 rules for the same bug - no-adhoc-regex-escape and no-private-binary-resolution short-circuit only to exempt their own seam file, which is the right shape, and no-crlf-fragile-split has no filename gate at all. This bug is unique to the one rule. 2. no-adhoc-regex-escape could not see the shape that actually occurs. Line 396 gated the whole UNSAFE-NEW-REGEXP arm on arg.type === 'Identifier'. Every check below it - the _SOURCE provenance check, the isSoleReturnOfOwnParameter shape - lives inside that branch, so new RegExp(obj['key']) and new RegExp(cfg.pattern) were never examined at all. Runtime data arrives as a property access far more often than as a bare identifier, which is exactly why this rule never fired on the #3477 ReDoS. Widened to MemberExpression, measured by AST walk across all five registered blocks rather than by grep. 27 sites, zero TSAsExpression: 18 safe new RegExp(X.source, flags) -> exempted, keyed strictly on the PROPERTY being `source`, never on the object. Keying on the object would wave through X.anything and buy nothing. B6 estimated ~10; that was an undercount. 3 _SOURCE-suffixed constants reached through a required module namespace (phaseId.BRACKET_PHASE_TOKEN_SOURCE) -> the same provenance-exempt class the rule already recognizes for bare identifiers, extended to reach them. Without this the widening produces 3 false flags. 6 real findings -> marked, each a test extracting a pattern from a shipped file at test time, where the runtime contract IS the product. Deliberately the NARROW MemberExpression form. The rule's own isSoleReturnOfOwnParameter doc comment records that an earlier broad "any non-literal identifier" heuristic produced ~25 false positives and was rejected; a re-run of the census after this change flags exactly the 6 above and nothing else. Verified by execution, not by reading: the gate now accepts src/<subdir>/x.cts, still accepts flat src/x.cts, and still exempts paths outside src/ - each pinned by a test proven to fail against the old regex. build:lib, lint and lint:ci all exit 0. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3951): give no-adhoc-markdown-parsing its reach, and fix the 80 parses it finds The rule self-gates on filename AND is registered on one glob, so widening either half alone is inert. Both move here: the gate now accepts tests/**/*.cjs and scripts/**/*.cjs alongside src/**/*.cts, and eslint.config.mjs registers it on the same two. A test pins that the gate and the registration AGREE, in both directions. The original defect was a gate narrower than its registration; the failure mode of this fix is a gate wider than its registration. Both are silent, so the test asserts the pair rather than either half. 80 violations across 43 files, all in tests/, zero in scripts/. 70 are routed through the existing seams - scanFencedBlocks, collectSection, stripFencedCode, tokenizeHeadings from markdown-sectionizer; splitTableRow, parseMarkdownTable, findTableWithColumns from markdown-table. Headerless STATE.md tables use splitTableRow per line, because parseMarkdownTable needs a real delimiter row. 10 are suppressed, 12.5%, well under the third that would have meant the rule is mis-scoped for tests/ rather than the tests carrying debt. Each names its reason: three regression guards (#3873 / bug-#21) are deliberately independent of the generator's own fence handling, and routing them through the seam would have them test the generator against itself; one is a negative-text probe that extracts nothing; six are a shell-pipe-to-jq detector whose regex coincidentally matches the table fingerprint and is not markdown parsing at all. All ten sit in tests whose subject is .md content, which is normally a reason to prefer the seam. The marker used is allow-adhoc-markdown, distinct from no-source-grep's allow-test-rule, and lint:ci's lint-allow-test-rule-refs reports the same 280/280 unverified count as before - checked rather than assumed, because those two markers are easy to conflate. The widening earned its keep immediately: it found a test that passed for the wrong reason. tests/config-field-docs.test.cjs asserted notEqual(<cell>, '600') against the TYPE column instead of the DEFAULT column. notEqual('number', '600') is true forever, so the guard against workflow.subagent_timeout regressing to the old seconds default could never fire. docs/CONFIGURATION.md:434 is `| workflow.subagent_timeout | number | 300000 | ... |`, so the default is cell index 2; the assertion is now row-scoped through splitTableRow and reads 300000. That is the argument for the widening in one case: the violation was invisible to lint, the suite was green, and the assertion was vacuous. A rule that cannot reach a file cannot tell you the file is lying. Not fixed here, and recorded rather than assumed: #3426/#3239 are NOT reachable by this widening. tests/package-legitimacy-gate.test.cjs yields zero violations even with the gate bypassed - its hand-rolled scans are real, but built from line filters and split('|') rather than the regex-literal fingerprints this rule detects. They need new detectors. The epic assumed a wider glob would catch them. build:lib, lint and lint:ci all exit 0; the post-fix census across tests/** and scripts/** is 0 violations. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3951): B7 — and #3356's defects were still live in the code B7 asks that each closed child be driven fail-first with a behavioral identity test at the CONSUMER's output. Four of eleven children had no test citing their issue number. Auditing them by BEHAVIOR rather than by number-grep changed the answer for three of the four. #3364 and #2540 — traceability only. Both were implemented by #3941 and their consumer-output tests exist and were shown failing-first; neither cited its originating issue, so an audit that greps for the number reports them uncovered. Tagged the specific asserting test in each file, following the citation form those files already use. #3372 — covered, but only at helper level, and the triage narrowed it. Of the four commands the issue names, only estimate-cli's collectCalibrationSamples actually enumerates phase dirs from disk; smart-entry, audit and roadmap-upgrade derive from ROADMAP/body text and never reach the sentinel path, so they are benign by construction and were left alone rather than "fixed" into churn. The existing #3882 rows asserted the helper's return value. Added a consumer-output test driving `query estimate-calibrate` and asserting sample_count and the persisted document. RED proof: reverted collectCalibrationSamples to a raw readdirSync and ran the real CLI - sample_count 3, sentinel leaked; restored - sample_count 2. #3356 — NOT covered, and BOTH halves of the defect were still live in source. The issue is closed; the bug was not fixed. Fixed here rather than writing tests that document a bug as correct. Defect 1, the contradicted row. quick.md:627 claimed `quick-tasks-append` performs "the equivalent write" to the Step 7c row. It did not: the `#` cell was a positional ordinal and `Directory` read `—`, because the route had no way to receive a quick id or task directory. Added OPTIONAL `--quick-id` / `--slug` / `--directory`. A caller with neither - fast.md, the original #2133 caller - omits them and gets the byte-identical prior row, so nothing existing changes. A caller that HAS a real id and directory now gets the canonical row quick.md:632 renders. The false-equivalence sentence itself is corrected rather than left to mislead the next reader. Defect 2, the forced re-derive. The route called readModifyWriteStateMd with no options, so a body-only append to the Quick Tasks table triggered a full re-derive of the disk-derived progress.* frontmatter. Every other body-only writer passes { resync: false } - src/state.cts's own docstring prescribes it - and this route was the lone outlier. RED proof: reverted the option, seeded a project with 2 real phase dirs and a curated total_phases of 25, ran quick-tasks-append; total_phases collapsed to 2. Restored; it stayed 25. That second one is the shape this epic exists to close: a silent write that replaces curated state with a re-derivation nobody asked for, exit 0 throughout. build:lib, lint and lint:ci all exit 0. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3951): amend B6's ledger to what was measured, and document the new flags The ADR gains a ledger amendment in its own correction style - the sixth wrong premise it records, found the same way as the other five, by measuring before building. B6 says the net guard count must fall. It rose: 62 -> 69, +7, measured from the epic's filing commit to origin/next. The attribution is the point, though. Five of the seven came from PRs unrelated to this epic, one was added by a phase of it, and the epic did retire something sub-file - #3884 removed a detector with an explicit "net: -1 detector, 0 added" ledger. Every named casualty is load-bearing, two already carry retractions in this same document, and a sweep of all 22 rules plus every scripts/lint-* found no provably dead guard. There is no honest way to make the count fall; forcing it would trade coverage for a number, which is the Goodhart outcome Decision 6 exists to prevent. The amendment also records that B6's own prescribed fix for one widening was inert. no-adhoc-markdown-parsing self-gates on its filename, so widening only the files: glob - which is what the criterion says to do - ships a rule that still returns {} for every new path. And #3426/#3239 are not reachable by that widening at all; their scans use line filters and split('|'), not the regex fingerprints the rule detects. The roster row tracked them against the wrong mechanism. Three roster rows updated from aspiration to fact: the two widenings are DONE with their measured counts, and lint-phase-enumeration-drift is marked RETAINED rather than "expected casualty - verify before retiring", because Phase 5 verified it and kept it. The rule Decision 6 should carry forward is stated plainly: a guard ledger is a claim about COVERAGE, not about COUNT. "Net count must fall" is measurable and wrong. "Every guard is reachable, and each retirement names what makes its defect unrepresentable" is the property that was actually wanted. CLI-TOOLS.md documents the optional --quick-id/--slug/--directory flags and says plainly that omitting them keeps the pre-#3356 row byte-identical, plus that the append no longer re-derives progress frontmatter. New features fragment (id 3951); FEATURES.md regenerated rather than hand-edited. Changeset is Changed, pr:0 pending backfill. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3951): correct four rows that pinned the lint rule's old narrow reach The remote suite came back RED with 5 failures, all in tests/eslint-rules.test.cjs. They are stale tests, not a regression: four rows assert that no-adhoc-markdown-parsing is inert outside src/*.cts, which is exactly the contract this deliverable changes. Confirmed by reading rather than inferred from the names - the row at :1981 used filename: 'tests/some.test.cjs' and filename: 'scripts/helper.cjs', the two roots the rule now covers on purpose. Worth recording WHY local gates missed this. npm run lint and lint:ci were green, and the touched test files passed standalone. Lint only reports violations in real files; these rows assert the rule's REACH using synthetic RuleTester filenames, so nothing but the full suite could see them. Local green on a rule change says nothing about the rule's own tests. Each row is rewritten with BOTH halves rather than flipped from valid to invalid: - the same fingerprint under tests/ or scripts/ is now flagged, with the right messageId - the negative space is preserved - the same fingerprint under a path outside all three roots (gsd-core/bin/lib/foo.cjs) is still NOT flagged The second half is the one that matters. Without it the rule has no boundary and nothing would catch an over-wide gate later, which is the mirror image of the bug this deliverable just fixed. Each row is renamed to state the current contract; the old names said "non-src/*.cts ... is not flagged" and would have been actively misleading once the bodies changed. Proven to test the widening rather than restate it: every flagged half was run against HEAD~2's pre-widening rule and does NOT fire there, then against the current rule and does. 12/12 on that probe; the full file is 178/178. Swept for the same staleness elsewhere and found none. require-subprocess-timeout's own "inert outside src/*.cts" row is untouched - that rule's gate was not widened here - and no-adhoc-regex-escape's test file already carries correctly-targeted rows. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3951): acknowledge the quick.md growth the attribution guard reported The full suite came back RED with one failure, and it is mine: 1 file(s) grew without an acknowledgment: quick.md grew 364 bytes gsd-core/workflows/quick.md is runtime-loaded emitted content, so correcting its false 'performs the equivalent write' claim trips emitted-attribution by construction. This is the acknowledgment, not a workaround - there is nothing to regenerate. The fragment names ONE path, which is the only one the guard reported. The four spent acknowledgments it also listed (audit-uat, plan-phase, progress, review) belong to other fragments whose ripple the base already absorbs; they are inert, not failures, and are deliberately NOT copied here - naming paths I did not change would make this record false in the other direction. Byte figure corrected before committing: the guard reported 37220 -> 37584 (+364), but origin/next has since moved and quick.md is 37232 there now, so the measured delta is +352. The reason text says so and names the base as a moving figure rather than pinning a number that is already stale. Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3951): move the quick.md growth ack to a trailer, delete the obsolete fragment The acknowledgment mechanism changed under this branch. Merging next brought in the redesign - it also deleted .github/workflows/ack-fragment-sweep.yml, which was in the merge status and which I did not register at the time - and the guard now says so directly: Add a trailer to a commit in this PR (never a new file). Emitted-Drift-Ack-Growth: quick.md - <why this growth is deliberate> So tests/emitted-drift-acks/3951-quick-append-equivalence.json is obsolete on arrival. A fragment file is no longer read by anything, and leaving it would be a dead record that looks like an active one. It is deleted here rather than kept "just in case". The byte figure moved again with the merge: 37232 -> 37596, +364. The earlier fragment said +352, measured before the merge auto-merged quick.md itself. The trailer carries no number, which is the better design - the figure was stale twice in two attempts. Refs #3951 Emitted-Drift-Ack-Growth: quick.md — #3356/#3951 replaces a false claim with an accurate one. Line 627 said the `quick-tasks-append` shortcut "performs the equivalent write" to the Step 7c row rendered above it; it did not, and that was the documented half of #3356 — with no quick id or task directory the route emitted a positional ordinal in `#` and an em-dash in `Directory`, a visibly different row. The corrected sentence has to carry three facts the original elided: what the shortcut actually writes when it has neither input, that this is honest behavior for its real caller (`fast.md`, which has neither), and how a caller with both now gets the byte-identical canonical row via the new optional `--quick-id`/`--slug`/`--directory` flags. Prose is the product here — an executing agent reads this line to decide whether the shortcut is safe for its case, and a shorter correction would either drop the flags (leaving the reader unable to act on the fix) or drop the limitation (recreating the false claim in gentler words). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3951): backfill changeset pr number Refs #3951 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
3746 lines
164 KiB
JavaScript
3746 lines
164 KiB
JavaScript
'use strict';
|
|
|
|
// Phase 1 tests for the STATE.md Transition Module (ADR-1769).
|
|
// These are characterization tests: they pin the behavior the new
|
|
// `transitionCore` / `beginPhase` API must preserve as the old
|
|
// `cmdStateBeginPhase` callback in state.cts is migrated onto it.
|
|
//
|
|
// Discipline: TDD vertical slices. One behavior → one test → minimal code → repeat.
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fc = require('fast-check');
|
|
|
|
const {
|
|
transitionCore,
|
|
applyStatePreservation,
|
|
openStateTransaction,
|
|
rebuildStateTransaction,
|
|
FIELD_CLASSIFICATION,
|
|
FRONTMATTER_BODY_SOURCE,
|
|
getFieldClassification,
|
|
getPreserveWhenUnchangedFields,
|
|
STATE_MD_SECTIONS,
|
|
sliceCurrentPositionSection,
|
|
} = require('../gsd-core/bin/lib/state-transition.cjs');
|
|
const { stateExtractField } = require('../gsd-core/bin/lib/state-document.cjs');
|
|
const { STATE_FIELD_SCHEMA } = require('../gsd-core/bin/lib/state-md-schema.cjs');
|
|
|
|
const fixedClock = Object.freeze({
|
|
today: () => '2026-06-27',
|
|
localToday: () => '2026-06-27',
|
|
nowIso: () => '2026-06-27T12:00:00.000Z',
|
|
});
|
|
|
|
describe('ADR-1769 substrate: field-classification table', () => {
|
|
const allowedSources = new Set(['body', 'disk', 'external', 'curated', 'free']);
|
|
const allowedPreservation = new Set([
|
|
'derive',
|
|
'preserve-when-unchanged',
|
|
'preserve-always',
|
|
'preserve-if-placeholder',
|
|
]);
|
|
|
|
test('every classified field has a { source, preservation } row with known enum values', () => {
|
|
for (const [field, cls] of Object.entries(FIELD_CLASSIFICATION)) {
|
|
assert.ok(
|
|
allowedSources.has(cls.source),
|
|
`field ${JSON.stringify(field)} has unknown source ${JSON.stringify(cls.source)}`,
|
|
);
|
|
assert.ok(
|
|
allowedPreservation.has(cls.preservation),
|
|
`field ${JSON.stringify(field)} has unknown preservation ${JSON.stringify(cls.preservation)}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('current_phase_name is curated / preserve-when-unchanged (ADR-1769 §4 — kills #1743/#1695 by construction; ADR-3408 #3468 reclassified from preserve-always to match its long-standing delta-gated behavior)', () => {
|
|
const cls = getFieldClassification('current_phase_name');
|
|
assert.strictEqual(cls && cls.source, 'curated');
|
|
assert.strictEqual(cls && cls.preservation, 'preserve-when-unchanged');
|
|
});
|
|
|
|
test('progress is curated / preserve-always (ADR-1769 §4 — curated-progress ratchet)', () => {
|
|
const cls = getFieldClassification('progress');
|
|
assert.strictEqual(cls && cls.source, 'curated');
|
|
assert.strictEqual(cls && cls.preservation, 'preserve-always');
|
|
});
|
|
|
|
test('state_head is free / derive (ADR-1769 §4 — ambient git read, refreshed every write; #2573)', () => {
|
|
// `state_head` records the commit STATE.md was written against. It is not
|
|
// body-derived, disk-derived, or curated — it is an ambient external read
|
|
// recomputed on every write, exactly like `last_updated` (realClock.nowIso()).
|
|
// ADR-1769 §4: "Each STATE.md field has a row." The per-transition guard in
|
|
// transitionCore only checks the keys a transition declares, so an
|
|
// unregistered field would slip through silently — this test is the check.
|
|
const cls = getFieldClassification('state_head');
|
|
assert.strictEqual(cls && cls.source, 'free');
|
|
assert.strictEqual(cls && cls.preservation, 'derive');
|
|
});
|
|
|
|
test('table covers every frontmatter key emitted by buildStateFrontmatter (codex Phase 1 review)', () => {
|
|
// Verified against src/state.cts:1633-1653 (buildStateFrontmatter emit block).
|
|
const requiredFields = [
|
|
'gsd_state_version',
|
|
'milestone',
|
|
'milestone_name',
|
|
'current_phase',
|
|
'current_phase_name',
|
|
'current_plan',
|
|
'status',
|
|
'stopped_at',
|
|
'paused_at',
|
|
'last_updated',
|
|
'last_activity',
|
|
'last_activity_desc',
|
|
'state_head',
|
|
'progress',
|
|
'progress.total_phases',
|
|
'progress.completed_phases',
|
|
'progress.total_plans',
|
|
'progress.completed_plans',
|
|
'progress.percent',
|
|
];
|
|
for (const f of requiredFields) {
|
|
assert.ok(getFieldClassification(f) !== null,
|
|
`frontmatter key ${JSON.stringify(f)} must have a classification row`);
|
|
}
|
|
});
|
|
|
|
test('getFieldClassification returns null for unknown fields AND inherited prototype methods', () => {
|
|
// Classic prototype-pollution guard: queries for 'toString' / 'valueOf' / '__proto__'
|
|
// must return null, not inherited Object.prototype functions.
|
|
assert.strictEqual(getFieldClassification('toString'), null);
|
|
assert.strictEqual(getFieldClassification('valueOf'), null);
|
|
assert.strictEqual(getFieldClassification('hasOwnProperty'), null);
|
|
assert.strictEqual(getFieldClassification('__proto__'), null);
|
|
assert.strictEqual(getFieldClassification('not-a-real-field'), null);
|
|
});
|
|
});
|
|
|
|
// #3873 (ADR-3473 §8.8): `FIELD_CLASSIFICATION` and `FRONTMATTER_BODY_SOURCE`
|
|
// are now PROJECTIONS of `STATE_FIELD_SCHEMA` (src/state-md-schema.cts),
|
|
// derived at module load rather than hand-maintained beside it. The
|
|
// comparands below are today's literal tables, copied VERBATIM (not
|
|
// re-derived from the schema — a parity test that builds both sides from the
|
|
// same source proves nothing; see 50-test-matrix.md's "writer-seeded fixture
|
|
// trap" note), captured by direct read of `src/state-transition.cts` on this
|
|
// branch's base (pre-#3873) before the projection replaced them. Any drift
|
|
// here is a silently-changed preservation policy — the #3427 failure this
|
|
// epic is named after.
|
|
describe('ADR-3473 §8.8 (#3873): the three tables are byte-identical projections of STATE_FIELD_SCHEMA', () => {
|
|
// Verbatim copy of `FIELD_CLASSIFICATION`'s pre-#3873 literal (19 rows, this
|
|
// exact key order — key order is observable: the preservation dispatch loop
|
|
// and `getPreserveWhenUnchangedFields` both iterate it).
|
|
const TODAYS_FIELD_CLASSIFICATION = Object.freeze({
|
|
gsd_state_version: { source: 'free', preservation: 'derive' },
|
|
milestone: { source: 'external', preservation: 'preserve-if-placeholder' },
|
|
milestone_name: { source: 'external', preservation: 'preserve-if-placeholder' },
|
|
current_phase: { source: 'body', preservation: 'preserve-when-unchanged' },
|
|
current_phase_name: { source: 'curated', preservation: 'preserve-when-unchanged' },
|
|
current_plan: { source: 'body', preservation: 'preserve-when-unchanged' },
|
|
status: { source: 'body', preservation: 'preserve-when-unchanged', guard: 'non-sentinel-unknown' },
|
|
stopped_at: { source: 'body', preservation: 'preserve-when-unchanged' },
|
|
paused_at: { source: 'body', preservation: 'preserve-when-unchanged' },
|
|
last_updated: { source: 'free', preservation: 'derive' },
|
|
last_activity: { source: 'body', preservation: 'derive' },
|
|
last_activity_desc: { source: 'body', preservation: 'preserve-when-unchanged' },
|
|
state_head: { source: 'free', preservation: 'derive' },
|
|
progress: { source: 'curated', preservation: 'preserve-always', mergeStrategy: 'progress-ratchet' },
|
|
'progress.total_phases': { source: 'disk', preservation: 'derive' },
|
|
'progress.completed_phases': { source: 'disk', preservation: 'derive' },
|
|
'progress.total_plans': { source: 'disk', preservation: 'derive' },
|
|
'progress.completed_plans': { source: 'disk', preservation: 'derive' },
|
|
'progress.percent': { source: 'disk', preservation: 'derive' },
|
|
});
|
|
|
|
// Verbatim copy of `FRONTMATTER_BODY_SOURCE`'s pre-#3873 literal (8 keys,
|
|
// this exact key order — deliberately NOT the same order as
|
|
// `FIELD_CLASSIFICATION` above, nor the same order as
|
|
// `FRONTMATTER_KEY_TO_BODY_LABEL` in tests/state.test.cjs; the two
|
|
// pre-existing tables disagreed with each other's order too).
|
|
const TODAYS_FRONTMATTER_BODY_SOURCE = Object.freeze({
|
|
current_phase: ['Current Phase'],
|
|
current_phase_name: ['Current Phase Name'],
|
|
current_plan: ['Current Plan'],
|
|
status: ['Status'],
|
|
stopped_at: ['Stopped At', 'Stopped at'],
|
|
paused_at: ['Paused At'],
|
|
last_activity: ['Last Activity', 'Last activity'],
|
|
last_activity_desc: ['Last Activity Description'],
|
|
});
|
|
|
|
test('row 1 — fieldClassificationProjectionMatchesTodaysTable', () => {
|
|
assert.deepStrictEqual(
|
|
Object.keys(FIELD_CLASSIFICATION),
|
|
Object.keys(TODAYS_FIELD_CLASSIFICATION),
|
|
'FIELD_CLASSIFICATION key order must be unchanged',
|
|
);
|
|
for (const key of Object.keys(TODAYS_FIELD_CLASSIFICATION)) {
|
|
assert.deepStrictEqual(
|
|
FIELD_CLASSIFICATION[key],
|
|
TODAYS_FIELD_CLASSIFICATION[key],
|
|
`FIELD_CLASSIFICATION[${JSON.stringify(key)}] must be byte-identical to today's table`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('row 2 — bodySourceProjectionMatchesTodaysTable', () => {
|
|
assert.deepStrictEqual(
|
|
Object.keys(FRONTMATTER_BODY_SOURCE),
|
|
Object.keys(TODAYS_FRONTMATTER_BODY_SOURCE),
|
|
'FRONTMATTER_BODY_SOURCE key order must be unchanged',
|
|
);
|
|
for (const key of Object.keys(TODAYS_FRONTMATTER_BODY_SOURCE)) {
|
|
assert.deepStrictEqual(
|
|
[...FRONTMATTER_BODY_SOURCE[key]],
|
|
TODAYS_FRONTMATTER_BODY_SOURCE[key],
|
|
`FRONTMATTER_BODY_SOURCE[${JSON.stringify(key)}] must be byte-identical to today's table`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('row 5 — projectionIsFrozenAndNullPrototype (FIELD_CLASSIFICATION, FRONTMATTER_BODY_SOURCE)', () => {
|
|
assert.ok(Object.isFrozen(FIELD_CLASSIFICATION));
|
|
assert.strictEqual(FIELD_CLASSIFICATION['toString'], undefined);
|
|
assert.ok(Object.isFrozen(FRONTMATTER_BODY_SOURCE));
|
|
assert.strictEqual(FRONTMATTER_BODY_SOURCE['toString'], undefined);
|
|
// Per-row objects/arrays keep their pre-#3873 shape too: FIELD_CLASSIFICATION's
|
|
// rows were plain (non-frozen, non-null-prototype) literals, and this
|
|
// projection reproduces that exactly rather than "improving" it.
|
|
assert.strictEqual(Object.isFrozen(FIELD_CLASSIFICATION.status), false);
|
|
assert.ok(Object.isFrozen(FRONTMATTER_BODY_SOURCE.stopped_at));
|
|
});
|
|
|
|
test('row 6 — everyClassificationRowStillResolves (including the five progress.* rows)', () => {
|
|
for (const key of Object.keys(TODAYS_FIELD_CLASSIFICATION)) {
|
|
assert.notStrictEqual(getFieldClassification(key), null, `${key} must still resolve`);
|
|
}
|
|
for (const leaf of ['progress.total_phases', 'progress.completed_phases', 'progress.total_plans', 'progress.completed_plans', 'progress.percent']) {
|
|
const cls = getFieldClassification(leaf);
|
|
assert.strictEqual(cls.source, 'disk');
|
|
assert.strictEqual(cls.preservation, 'derive');
|
|
}
|
|
});
|
|
|
|
test('row 7 — preserveWhenUnchangedProjectionUnchanged', () => {
|
|
assert.deepStrictEqual(
|
|
getPreserveWhenUnchangedFields(),
|
|
['current_phase', 'current_phase_name', 'current_plan', 'status', 'stopped_at', 'paused_at', 'last_activity_desc'],
|
|
);
|
|
});
|
|
|
|
test('row 8 — schemaMayDeclareMoreThanAProjectionConsumes', () => {
|
|
// `milestone` is a real STATE_FIELD_SCHEMA row (external/preserve-if-placeholder)
|
|
// with no body source at all, so it is legitimately absent from
|
|
// FRONTMATTER_BODY_SOURCE — projections are subsets by design (D4).
|
|
assert.ok(Object.prototype.hasOwnProperty.call(STATE_FIELD_SCHEMA, 'milestone'));
|
|
assert.strictEqual(STATE_FIELD_SCHEMA.milestone.bodySource, undefined);
|
|
assert.strictEqual(
|
|
Object.prototype.hasOwnProperty.call(FRONTMATTER_BODY_SOURCE, 'milestone'),
|
|
false,
|
|
'a schema row with no bodySource must not appear in the FRONTMATTER_BODY_SOURCE projection',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('ADR-1769 substrate: STATE_MD_SECTIONS constants (aligned to gsd-core/templates/state.md)', () => {
|
|
test('every section heading starts with "## "', () => {
|
|
for (const [name, heading] of Object.entries(STATE_MD_SECTIONS)) {
|
|
assert.ok(
|
|
heading.startsWith('## '),
|
|
`section ${name} heading ${JSON.stringify(heading)} must start with "## "`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('matches the six canonical top-level sections of the STATE.md template', () => {
|
|
assert.strictEqual(STATE_MD_SECTIONS.projectReference, '## Project Reference');
|
|
assert.strictEqual(STATE_MD_SECTIONS.currentPosition, '## Current Position');
|
|
assert.strictEqual(STATE_MD_SECTIONS.performanceMetrics, '## Performance Metrics');
|
|
assert.strictEqual(STATE_MD_SECTIONS.accumulatedContext, '## Accumulated Context');
|
|
assert.strictEqual(STATE_MD_SECTIONS.deferredItems, '## Deferred Items');
|
|
assert.strictEqual(STATE_MD_SECTIONS.sessionContinuity, '## Session Continuity');
|
|
});
|
|
});
|
|
|
|
describe('ADR-1769 Phase 1: beginPhase transition — tracer bullet', () => {
|
|
test('updates body Status field to "Executing Phase N" on first-time begin', () => {
|
|
const input = [
|
|
'# Project State',
|
|
'',
|
|
'**Status:** Planning',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 2 — DONE',
|
|
'Plan: —',
|
|
'Status: Planning',
|
|
'',
|
|
].join('\n');
|
|
|
|
const result = transitionCore(
|
|
input,
|
|
{ kind: 'beginPhase', phaseNumber: 3, phaseName: 'Test Phase', planCount: 5 },
|
|
{ clock: fixedClock },
|
|
);
|
|
|
|
assert.ok(result.updated.includes('Status'), `updated should include Status; got ${JSON.stringify(result.updated)}`);
|
|
// The transition must produce a body Status field carrying "Executing Phase 3".
|
|
// Use the same primitive the production code uses, not a source-grep.
|
|
const bodyStatus = stateExtractField(result.content, 'Status');
|
|
assert.ok(
|
|
/Executing Phase\s+3\b/.test(bodyStatus || ''),
|
|
`body Status should match /Executing Phase 3/; got ${JSON.stringify(bodyStatus)}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
// Shared fixture for first-time begin: a clean STATE.md body where no
|
|
// "Executing Phase N" status is present yet.
|
|
function firstTimeBody() {
|
|
return [
|
|
'# Project State',
|
|
'',
|
|
'**Status:** Planning',
|
|
'**Current Phase:** 02',
|
|
'**Current Phase Name:** Previous Phase',
|
|
'**Current Plan:** 02',
|
|
'**Total Plans in Phase:** 3',
|
|
'**Last Activity:** 2026-06-20',
|
|
'**Last Activity Description:** previous work',
|
|
'**Current focus:** Phase 2 — Previous Phase',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 2 (Previous Phase)',
|
|
'Plan: 2 of 3',
|
|
'Status: Planning',
|
|
'Last activity: 2026-06-20 — context gathered',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
describe('ADR-1769 Phase 1: beginPhase first-time body field updates', () => {
|
|
const intent = { kind: 'beginPhase', phaseNumber: 3, phaseName: 'Test Phase', planCount: 5 };
|
|
const deps = { clock: fixedClock };
|
|
|
|
test('updates Current Phase to N', () => {
|
|
const result = transitionCore(firstTimeBody(), intent, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Current Phase'), '3');
|
|
assert.ok(result.updated.includes('Current Phase'));
|
|
});
|
|
|
|
test('updates Current Phase Name when phaseName is provided', () => {
|
|
const result = transitionCore(firstTimeBody(), intent, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Current Phase Name'), 'Test Phase');
|
|
assert.ok(result.updated.includes('Current Phase Name'));
|
|
});
|
|
|
|
test('sets Current Plan to 1 on first-time begin', () => {
|
|
const result = transitionCore(firstTimeBody(), intent, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '1');
|
|
assert.ok(result.updated.includes('Current Plan'));
|
|
});
|
|
|
|
test('updates Total Plans in Phase to planCount when provided', () => {
|
|
const result = transitionCore(firstTimeBody(), intent, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '5');
|
|
assert.ok(result.updated.includes('Total Plans in Phase'));
|
|
});
|
|
|
|
test('updates Last Activity to clock.today()', () => {
|
|
const result = transitionCore(firstTimeBody(), intent, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Last Activity'), '2026-06-27');
|
|
assert.ok(result.updated.includes('Last Activity'));
|
|
});
|
|
|
|
test('updates Last Activity Description to "Phase N execution started"', () => {
|
|
const result = transitionCore(firstTimeBody(), intent, deps);
|
|
assert.strictEqual(
|
|
stateExtractField(result.content, 'Last Activity Description'),
|
|
'Phase 3 execution started',
|
|
);
|
|
assert.ok(result.updated.includes('Last Activity Description'));
|
|
});
|
|
|
|
test('updates **Current focus:** body text line (#1104)', () => {
|
|
const result = transitionCore(firstTimeBody(), intent, deps);
|
|
// The **Current focus:** line should now carry the new phase label.
|
|
const focusMatch = result.content.match(/\*\*Current focus:\*\*\s*(.*)/i);
|
|
assert.ok(focusMatch, '**Current focus:** line must still be present');
|
|
assert.strictEqual(focusMatch[1].trim(), 'Phase 3 — Test Phase');
|
|
assert.ok(result.updated.includes('Current focus'),
|
|
`updated should include 'Current focus'; got ${JSON.stringify(result.updated)}`);
|
|
});
|
|
});
|
|
|
|
// Fixture for resume: a STATE.md body where Status already contains
|
|
// "Executing Phase 3" — the #3127 idempotency guard must detect this and
|
|
// skip the first-time-only field writes.
|
|
function resumeBody() {
|
|
return [
|
|
'# Project State',
|
|
'',
|
|
'**Status:** Executing Phase 3',
|
|
'**Current Phase:** 03',
|
|
'**Current Phase Name:** Test Phase',
|
|
'**Current Plan:** 02',
|
|
'**Total Plans in Phase:** 5',
|
|
'**Last Activity:** 2026-06-26',
|
|
'**Last Activity Description:** mid-flight context from plan 3-02',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 3 (Test Phase) — EXECUTING',
|
|
'Plan: 2 of 5',
|
|
'Status: Executing Phase 3',
|
|
'Last activity: 2026-06-26 — mid-flight context',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
describe('ADR-1769 Phase 1: #3127 idempotency guard — resume path', () => {
|
|
const intent = { kind: 'beginPhase', phaseNumber: 3, phaseName: 'Test Phase', planCount: 5 };
|
|
const deps = { clock: fixedClock };
|
|
|
|
test('Status is still refreshed on resume (Last Activity Date tracks execute-phase runs)', () => {
|
|
const result = transitionCore(resumeBody(), intent, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Last Activity'), '2026-06-27');
|
|
assert.ok(result.updated.includes('Last Activity'));
|
|
});
|
|
|
|
test('Current Plan is NOT overwritten on resume (#3127 — preserves mid-flight plan number)', () => {
|
|
const result = transitionCore(resumeBody(), intent, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '02');
|
|
assert.ok(!result.updated.includes('Current Plan'),
|
|
`Current Plan must not be in updated on resume; got ${JSON.stringify(result.updated)}`);
|
|
});
|
|
|
|
test('Total Plans in Phase is NOT overwritten on resume', () => {
|
|
const result = transitionCore(resumeBody(), intent, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '5');
|
|
assert.ok(!result.updated.includes('Total Plans in Phase'));
|
|
});
|
|
|
|
test('Last Activity Description is NOT overwritten on resume (#3127 — preserves mid-flight context)', () => {
|
|
const result = transitionCore(resumeBody(), intent, deps);
|
|
assert.strictEqual(
|
|
stateExtractField(result.content, 'Last Activity Description'),
|
|
'mid-flight context from plan 3-02',
|
|
);
|
|
assert.ok(!result.updated.includes('Last Activity Description'));
|
|
});
|
|
|
|
test('Current Phase Name is NOT overwritten on resume', () => {
|
|
const result = transitionCore(resumeBody(), intent, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Current Phase Name'), 'Test Phase');
|
|
assert.ok(!result.updated.includes('Current Phase Name'));
|
|
});
|
|
});
|
|
|
|
describe('ADR-1769 Phase 1: Current Position section mutation (first-time begin)', () => {
|
|
const intent = { kind: 'beginPhase', phaseNumber: 3, phaseName: 'Test Phase', planCount: 5 };
|
|
const deps = { clock: fixedClock };
|
|
|
|
test('Current Position Phase line reflects the new phase (EXECUTING)', () => {
|
|
const result = transitionCore(firstTimeBody(), intent, deps);
|
|
assert.ok(result.updated.includes('Current Position'),
|
|
`updated should include Current Position; got ${JSON.stringify(result.updated)}`);
|
|
// Verify by extracting Phase from the result content (covers both inline and pipe-table).
|
|
// The transition writes "Phase: 3 (Test Phase) — EXECUTING" into ## Current Position.
|
|
// stateExtractField returns the first match across the whole content, but the
|
|
// **Current Phase:** frontmatter-style line is a different field, so 'Phase'
|
|
// extraction finds the Current Position line.
|
|
const posPhase = stateExtractField(result.content, 'Phase');
|
|
assert.ok(
|
|
/3.*Test Phase.*EXECUTING/.test(posPhase || ''),
|
|
`Current Position Phase line should match /3.*Test Phase.*EXECUTING/; got ${JSON.stringify(posPhase)}`,
|
|
);
|
|
});
|
|
|
|
test('Current Position Plan line shows "1 of N"', () => {
|
|
const result = transitionCore(firstTimeBody(), intent, deps);
|
|
const posPlan = stateExtractField(result.content, 'Plan');
|
|
assert.ok(
|
|
/1 of 5/.test(posPlan || ''),
|
|
`Current Position Plan line should match /1 of 5/; got ${JSON.stringify(posPlan)}`,
|
|
);
|
|
});
|
|
|
|
test('Current Position Status line reflects Executing Phase N', () => {
|
|
const result = transitionCore(firstTimeBody(), intent, deps);
|
|
// 'Status' extraction returns the first match — which is the top-level
|
|
// **Status:** line. The Current Position Status line is a different field
|
|
// occurrence. Extract from the section to disambiguate.
|
|
const { tokenizeHeadings } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
|
|
const body = result.content;
|
|
const hs = tokenizeHeadings(body);
|
|
const posIdx = hs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
|
|
assert.notStrictEqual(posIdx, -1, 'Current Position section must exist');
|
|
// Slice the section body and look for the Status line within it.
|
|
const h = hs[posIdx];
|
|
const lines = body.split('\n');
|
|
const hl = lines[h.line - 1];
|
|
const bodyStart = h.offset + hl.length + 1;
|
|
let bodyEnd = body.length;
|
|
for (let j = posIdx + 1; j < hs.length; j++) {
|
|
if (hs[j].level >= 2) { bodyEnd = hs[j].offset - 1; break; }
|
|
}
|
|
const sectionBody = body.slice(bodyStart, bodyEnd);
|
|
const sectionStatus = stateExtractField(sectionBody, 'Status');
|
|
assert.ok(
|
|
/Executing Phase\s+3/.test(sectionStatus || ''),
|
|
`Current Position Status line should match /Executing Phase 3/; got ${JSON.stringify(sectionStatus)}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('ADR-1769 Phase 1: Current Position section mutation (resume path)', () => {
|
|
const intent = { kind: 'beginPhase', phaseNumber: 3, phaseName: 'Test Phase', planCount: 5 };
|
|
const deps = { clock: fixedClock };
|
|
|
|
test('Resume updates only the Last activity line in Current Position (preserves Plan, Phase, Status)', () => {
|
|
const result = transitionCore(resumeBody(), intent, deps);
|
|
assert.ok(result.updated.includes('Last activity (resume)') || result.updated.includes('Last Activity'),
|
|
`resume should update Last activity; got ${JSON.stringify(result.updated)}`);
|
|
// Plan line in Current Position should still say "2 of 5" (NOT reset to "1 of 5").
|
|
const posPlan = stateExtractField(result.content, 'Plan');
|
|
assert.ok(
|
|
/2 of 5/.test(posPlan || ''),
|
|
`resume should preserve Plan "2 of 5"; got ${JSON.stringify(posPlan)}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('ADR-1769 Phase 1: property tests (RULESET.TESTS.property-based-testing)', () => {
|
|
const deps = { clock: fixedClock };
|
|
|
|
test('for any non-negative integer phaseNumber and any STATE.md body with a non-whitespace Status value, beginPhase produces content whose body Status carries "Executing Phase N"', () => {
|
|
// Note: filters out whitespace-only statusSuffix because state-document.cjs's
|
|
// bold stateReplaceField pattern uses greedy \s* that consumes the trailing
|
|
// newline when the value is whitespace-only — a pre-existing bug surfaced
|
|
// by this property test, not introduced by ADR-1769. Filed as a follow-up.
|
|
fc.assert(
|
|
fc.property(
|
|
fc.integer({ min: 0, max: 999 }),
|
|
fc.string({ minLength: 1 }).filter(s => s.trim().length > 0 && !s.includes('\u0000')),
|
|
(phaseNum, statusSuffix) => {
|
|
const input = `# Project State\n\n**Status:** ${statusSuffix}\n`;
|
|
const result = transitionCore(
|
|
input,
|
|
{ kind: 'beginPhase', phaseNumber: phaseNum, phaseName: null, planCount: null },
|
|
deps,
|
|
);
|
|
const bodyStatus = stateExtractField(result.content, 'Status') || '';
|
|
return new RegExp(`Executing Phase\\s+${phaseNum}\\b`).test(bodyStatus);
|
|
},
|
|
),
|
|
{ numRuns: 100 },
|
|
);
|
|
});
|
|
|
|
test('getFieldClassification own-property lookup always returns null or a valid {source, preservation} row', () => {
|
|
const allowedSources = new Set(['body', 'disk', 'external', 'curated', 'free']);
|
|
const allowedPreservation = new Set([
|
|
'derive',
|
|
'preserve-when-unchanged',
|
|
'preserve-always',
|
|
'preserve-if-placeholder',
|
|
]);
|
|
fc.assert(
|
|
fc.property(fc.string(), (s) => {
|
|
const cls = getFieldClassification(s);
|
|
if (cls === null) return true;
|
|
return allowedSources.has(cls.source) && allowedPreservation.has(cls.preservation);
|
|
}),
|
|
{ numRuns: 200 },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('ADR-1769 Phase 2: advancePlan transition', () => {
|
|
const deps = { clock: fixedClock };
|
|
|
|
test('advances Current Plan from N to N+1 (legacy format)', () => {
|
|
const input = [
|
|
'# Project State',
|
|
'',
|
|
'**Current Plan:** 02',
|
|
'**Total Plans in Phase:** 05',
|
|
'**Status:** Executing Phase 3',
|
|
'**Last Activity:** 2026-06-26',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Plan: 2 of 5',
|
|
'Status: Executing Phase 3',
|
|
'',
|
|
].join('\n');
|
|
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '3');
|
|
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);
|
|
});
|
|
|
|
test('phase-complete branch when currentPlan >= totalPlans', () => {
|
|
const input = [
|
|
'# Project State',
|
|
'',
|
|
'**Current Plan:** 05',
|
|
'**Total Plans in Phase:** 05',
|
|
'**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, false);
|
|
assert.strictEqual(result.data && result.data.reason, 'last_plan');
|
|
assert.strictEqual(result.data && result.data.status, 'ready_for_verification');
|
|
});
|
|
|
|
test('error when plan fields are unparseable', () => {
|
|
const input = '# Project State\n\nNo plan fields here.\n';
|
|
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
|
|
assert.strictEqual(result.data && result.data.error, true);
|
|
assert.deepStrictEqual(result.updated, []);
|
|
});
|
|
|
|
test('compound format: "Plan: 2 of 6" preserves compound shape', () => {
|
|
const input = [
|
|
'# Project State',
|
|
'',
|
|
'**Plan:** 2 of 6',
|
|
'**Status:** Executing Phase 3',
|
|
'**Last Activity:** 2026-06-26',
|
|
'',
|
|
].join('\n');
|
|
const result = transitionCore(input, { kind: 'advancePlan' }, deps);
|
|
const plan = stateExtractField(result.content, 'Plan');
|
|
assert.ok(/3 of 6/.test(plan || ''), `Plan should be "3 of 6"; got ${JSON.stringify(plan)}`);
|
|
assert.strictEqual(result.data && result.data.advanced, true);
|
|
});
|
|
});
|
|
|
|
// ─── #3873 phase-3 test-matrix rows 23/24/25: parser accepts EXACTLY the ────
|
|
// schema-declared shapes — no more, no fewer.
|
|
//
|
|
// Table-driven over every STATE_FIELD_SCHEMA row carrying `acceptedShapes`
|
|
// (today: only `current_plan`), so declaring `acceptedShapes` on a future row
|
|
// gets gated automatically. A row with a driver missing from PARSER_DRIVERS
|
|
// fails loudly (row24/row23 below) rather than silently skipping — that is
|
|
// what keeps the table-driven claim honest as rows are added.
|
|
//
|
|
// The declaration itself was corrected against OBSERVED parser behavior
|
|
// (verified by executing `advancePlanCore`, not by reading its prior
|
|
// docstring's claim): `current_plan.acceptedShapes` is `['N']` only.
|
|
// `Current Plan: N of M` standalone does NOT parse today — `advancePlanCore`
|
|
// (`src/state-transition.cts:1306`) requires a `Total Plans in Phase`
|
|
// sibling for the bare-`N` path; with no sibling and no separate `Plan`
|
|
// field, it falls to its NaN/NaN error branch. #3784 is the open issue for
|
|
// teaching it the hybrid shape; **PR #3791** ("fix(#3784): read the hybrid
|
|
// `Current Plan: N of M` shape, keep zero-padding, and name the accepted
|
|
// shapes on failure") is the in-flight fix. When #3791 merges,
|
|
// `acceptedShapes` MUST widen to `['N', 'N of M']` — until then, this suite
|
|
// 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('#3873 phase-3 rows 23/24/25: parser accepts exactly the schema-declared shapes', () => {
|
|
const deps = { clock: fixedClock };
|
|
|
|
// Bare `N` only ever appears in a real STATE.md paired with a sibling
|
|
// `Total Plans in Phase` field (that pairing is what supplies the total
|
|
// `advancePlanCore` needs). `N of M` / `N/M` are driven WITHOUT that
|
|
// sibling, because the entire point of a hybrid shape is that it is
|
|
// self-contained in the `Current Plan` field alone — pairing it with a
|
|
// sibling would let the sibling's total paper over a value `parseInt`
|
|
// cannot fully read, which is the exact coincidence row 25 exists to catch.
|
|
function driveCurrentPlanShape(shapeValue, { withTotalSibling = false } = {}) {
|
|
const lines = ['# Project State', '', `**Current Plan:** ${shapeValue}`];
|
|
if (withTotalSibling) lines.push('**Total Plans in Phase:** 5');
|
|
lines.push('**Status:** Executing Phase 3', '');
|
|
const result = transitionCore(lines.join('\n'), { kind: 'advancePlan' }, deps);
|
|
return !(result.data && result.data.error === true);
|
|
}
|
|
|
|
// field -> (shape value, drive opts) -> boolean "did it parse"
|
|
const PARSER_DRIVERS = {
|
|
current_plan: driveCurrentPlanShape,
|
|
};
|
|
|
|
// shape name -> [example value, drive opts]
|
|
const SHAPE_EXAMPLES = {
|
|
'N': ['3', { withTotalSibling: true }],
|
|
'N of M': ['3 of 5', {}],
|
|
'N/M': ['3/5', {}],
|
|
};
|
|
|
|
const rowsWithAcceptedShapes = Object.entries(STATE_FIELD_SCHEMA).filter(
|
|
([, row]) => Array.isArray(row.acceptedShapes),
|
|
);
|
|
|
|
test('sanity: at least one schema row declares acceptedShapes (else this suite is vacuous)', () => {
|
|
assert.ok(rowsWithAcceptedShapes.length > 0, 'expected at least one acceptedShapes row to pin');
|
|
});
|
|
|
|
test('unsupportedDeclaredShapeFails: every declared shape parses via the real parser (row 24)', () => {
|
|
for (const [field, row] of rowsWithAcceptedShapes) {
|
|
const driver = PARSER_DRIVERS[field];
|
|
assert.ok(driver, `no PARSER_DRIVERS entry for field ${JSON.stringify(field)} — register one before declaring acceptedShapes on it`);
|
|
for (const shape of row.acceptedShapes) {
|
|
const example = SHAPE_EXAMPLES[shape];
|
|
assert.ok(example, `no SHAPE_EXAMPLES entry for shape ${JSON.stringify(shape)} (field ${JSON.stringify(field)})`);
|
|
const [value, opts] = example;
|
|
const parsed = driver(value, opts);
|
|
assert.strictEqual(parsed, true, `declared shape ${JSON.stringify(shape)} for field ${JSON.stringify(field)} did not parse`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('undeclaredParserShapeFails: a shape outside the declared set is rejected (row 23)', () => {
|
|
// 'N of M' is deliberately excluded from current_plan's declared set
|
|
// today (the #3784/#3791 boundary); 'N/M' is a fourth spelling that has
|
|
// never been declared for any row. Both must fail to parse.
|
|
for (const [field, row] of rowsWithAcceptedShapes) {
|
|
const driver = PARSER_DRIVERS[field];
|
|
const undeclaredCandidates = Object.keys(SHAPE_EXAMPLES).filter((shape) => !row.acceptedShapes.includes(shape));
|
|
assert.ok(undeclaredCandidates.length > 0, `no undeclared shape candidate available to probe field ${JSON.stringify(field)}`);
|
|
for (const shape of undeclaredCandidates) {
|
|
const [value, opts] = SHAPE_EXAMPLES[shape];
|
|
const parsed = driver(value, opts);
|
|
assert.strictEqual(parsed, false, `undeclared shape ${JSON.stringify(shape)} for field ${JSON.stringify(field)} was accepted by the parser`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// The worked case (#3784's three spellings): current_plan specifically.
|
|
test('planNofMShapesAreExactlyTheDeclaredSet', () => {
|
|
assert.deepStrictEqual(Array.from(STATE_FIELD_SCHEMA.current_plan.acceptedShapes), ['N']);
|
|
|
|
// 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');
|
|
|
|
// 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');
|
|
});
|
|
});
|
|
|
|
describe('ADR-1769 Phase 2: advancePlan with frontmatter (#1255 pattern — codex review)', () => {
|
|
const deps = { clock: fixedClock };
|
|
|
|
test('advances plan correctly when STATE.md has YAML frontmatter (body Status not YAML status)', () => {
|
|
const input = [
|
|
'---',
|
|
'status: Executing Phase 3',
|
|
'current_phase: "03"',
|
|
'---',
|
|
'',
|
|
'# Project State',
|
|
'',
|
|
'**Current Plan:** 02',
|
|
'**Total Plans in Phase:** 05',
|
|
'**Status:** Executing Phase 3',
|
|
'**Last Activity:** 2026-06-26',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Plan: 2 of 5',
|
|
'Status: Executing Phase 3',
|
|
'',
|
|
].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 Status must be updated (not the YAML status key).
|
|
const bodyStatus = stateExtractField(result.content, 'Status');
|
|
assert.ok(
|
|
/Ready to execute/.test(bodyStatus || ''),
|
|
`body Status should be "Ready to execute"; got ${JSON.stringify(bodyStatus)}`,
|
|
);
|
|
assert.strictEqual(result.data && result.data.advanced, true);
|
|
});
|
|
});
|
|
|
|
// Shared fixture for completePhase: a STATE.md body mid-execution with the
|
|
// progress fields the cmdPhaseComplete transform touches. Mirrors the shape
|
|
// state.cts:buildStateFrontmatter emits.
|
|
function completePhaseBody() {
|
|
return [
|
|
'# Project State',
|
|
'',
|
|
'**Current Phase:** 3 of 5 (Old Name)',
|
|
'**Current Phase Name:** Old Name',
|
|
'**Current Plan:** 2',
|
|
'**Status:** Executing Phase 3',
|
|
'**Last Activity:** 2026-06-20',
|
|
'**Last Activity Description:** mid-flight',
|
|
'**Completed Phases:** 2',
|
|
'**Total Phases:** 5',
|
|
'**Progress:** 40%',
|
|
'percent: 40',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
// A roadmap with a progress table: 3 of 5 phases Complete → deriveProgressFromRoadmap
|
|
// returns { completedPhases: 3, totalPhases: 5 }.
|
|
// ADR-2143 (epic #2143): deriveProgressFromRoadmap now resolves this table via the
|
|
// markdown-table schema registry (TABLE_SCHEMAS.RoadmapProgress), which requires the
|
|
// exact canonical header (gsd-core/templates/roadmap.md); the 2nd column is named
|
|
// "Plans Complete" to match (its cell values here are unused free text, not M/N
|
|
// counts — no test in this file asserts totalPlans).
|
|
const ROADMAP_3_OF_5 = [
|
|
'## Roadmap',
|
|
'',
|
|
'| Phase | Plans Complete | Status | Completed |',
|
|
'| --- | --- | --- | --- |',
|
|
'| 1 | A | Complete | 2026-01-01 |',
|
|
'| 2 | B | Complete | 2026-02-01 |',
|
|
'| 3 | C | Complete | 2026-03-01 |',
|
|
'| 4 | D | In Progress | - |',
|
|
'| 5 | E | Pending | - |',
|
|
'',
|
|
].join('\n');
|
|
|
|
describe('ADR-1769 Phase 3: completePhase transition — body field updates', () => {
|
|
const deps = { clock: fixedClock, roadmapProvider: () => ROADMAP_3_OF_5 };
|
|
|
|
test('Current Phase advances to nextPhaseNum, preserving "of total" and appending the next name', () => {
|
|
const intent = {
|
|
kind: 'completePhase',
|
|
phaseNum: '3',
|
|
nextPhaseNum: '4',
|
|
nextPhaseName: 'Design Phase',
|
|
isLastPhase: false,
|
|
planCount: 3,
|
|
summaryCount: 3,
|
|
};
|
|
const result = transitionCore(completePhaseBody(), intent, deps);
|
|
const cp = stateExtractField(result.content, 'Current Phase');
|
|
assert.ok(
|
|
/^4 of 5 \(Design Phase\)$/.test(cp || ''),
|
|
`Current Phase should be "4 of 5 (Design Phase)"; got ${JSON.stringify(cp)}`,
|
|
);
|
|
assert.ok(result.updated.includes('Current Phase'));
|
|
});
|
|
|
|
test('Current Phase Name is set to nextPhaseName when provided', () => {
|
|
const intent = {
|
|
kind: 'completePhase',
|
|
phaseNum: '3',
|
|
nextPhaseNum: '4',
|
|
nextPhaseName: 'Design Phase',
|
|
isLastPhase: false,
|
|
planCount: 3,
|
|
summaryCount: 3,
|
|
};
|
|
const result = transitionCore(completePhaseBody(), intent, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Current Phase Name'), 'Design Phase');
|
|
});
|
|
|
|
test('Status becomes "Ready to plan" when not the last phase', () => {
|
|
const result = transitionCore(
|
|
completePhaseBody(),
|
|
{ kind: 'completePhase', phaseNum: '3', nextPhaseNum: '4', nextPhaseName: 'Design Phase', isLastPhase: false, planCount: 3, summaryCount: 3 },
|
|
deps,
|
|
);
|
|
assert.strictEqual(stateExtractField(result.content, 'Status'), 'Ready to plan');
|
|
});
|
|
|
|
test('Status becomes "All phases complete" when isLastPhase is true', () => {
|
|
const result = transitionCore(
|
|
completePhaseBody(),
|
|
{ kind: 'completePhase', phaseNum: '5', nextPhaseNum: null, nextPhaseName: null, isLastPhase: true, planCount: 2, summaryCount: 2 },
|
|
deps,
|
|
);
|
|
assert.strictEqual(stateExtractField(result.content, 'Status'), 'All phases complete');
|
|
});
|
|
|
|
test('Current Plan resets to "Not started"', () => {
|
|
const result = transitionCore(
|
|
completePhaseBody(),
|
|
{ kind: 'completePhase', phaseNum: '3', nextPhaseNum: '4', nextPhaseName: null, isLastPhase: false, planCount: 3, summaryCount: 3 },
|
|
deps,
|
|
);
|
|
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), 'Not started');
|
|
});
|
|
|
|
test('Last Activity Description carries transition narrative', () => {
|
|
const result = transitionCore(
|
|
completePhaseBody(),
|
|
{ kind: 'completePhase', phaseNum: '3', nextPhaseNum: '4', nextPhaseName: null, isLastPhase: false, planCount: 3, summaryCount: 3 },
|
|
deps,
|
|
);
|
|
assert.strictEqual(
|
|
stateExtractField(result.content, 'Last Activity Description'),
|
|
'Phase 3 complete, transitioned to Phase 4',
|
|
);
|
|
});
|
|
|
|
test('Last Activity Description has no transition clause when there is no next phase', () => {
|
|
const result = transitionCore(
|
|
completePhaseBody(),
|
|
{ kind: 'completePhase', phaseNum: '5', nextPhaseNum: null, nextPhaseName: null, isLastPhase: true, planCount: 2, summaryCount: 2 },
|
|
deps,
|
|
);
|
|
assert.strictEqual(stateExtractField(result.content, 'Last Activity Description'), 'Phase 5 complete');
|
|
});
|
|
});
|
|
|
|
describe('ADR-1769 Phase 3: completePhase progress derivation (roadmap)', () => {
|
|
const deps = { clock: fixedClock, roadmapProvider: () => ROADMAP_3_OF_5 };
|
|
|
|
test('Completed Phases is re-derived from the roadmap progress table', () => {
|
|
const result = transitionCore(
|
|
completePhaseBody(),
|
|
{ kind: 'completePhase', phaseNum: '3', nextPhaseNum: '4', nextPhaseName: null, isLastPhase: false, planCount: 3, summaryCount: 3 },
|
|
deps,
|
|
);
|
|
assert.strictEqual(stateExtractField(result.content, 'Completed Phases'), '3');
|
|
});
|
|
|
|
test('Progress percent is recomputed and the inline percent: token is updated', () => {
|
|
const result = transitionCore(
|
|
completePhaseBody(),
|
|
{ kind: 'completePhase', phaseNum: '3', nextPhaseNum: '4', nextPhaseName: null, isLastPhase: false, planCount: 3, summaryCount: 3 },
|
|
deps,
|
|
);
|
|
assert.strictEqual(stateExtractField(result.content, 'Progress'), '60%');
|
|
assert.ok(/percent:\s*60/.test(result.content), `inline percent: token should be 60; content was:\n${result.content}`);
|
|
});
|
|
|
|
test('when roadmapProvider yields null, existing Completed Phases / Progress are preserved (no crash)', () => {
|
|
const nullDeps = { clock: fixedClock, roadmapProvider: () => null };
|
|
const result = transitionCore(
|
|
completePhaseBody(),
|
|
{ kind: 'completePhase', phaseNum: '3', nextPhaseNum: '4', nextPhaseName: null, isLastPhase: false, planCount: 3, summaryCount: 3 },
|
|
nullDeps,
|
|
);
|
|
assert.strictEqual(stateExtractField(result.content, 'Completed Phases'), '2');
|
|
assert.strictEqual(stateExtractField(result.content, 'Progress'), '40%');
|
|
});
|
|
|
|
// #3057 B9: `result.updated` must let a caller tell "recomputed from the
|
|
// roadmap" apart from "roadmap unavailable, left as-is". Before the fix,
|
|
// `stateReplaceField`'s return was truthy whenever the field pattern
|
|
// matched — regardless of whether the substituted text actually differed
|
|
// from `body` — so 'Completed Phases' (and 'Progress') were marked
|
|
// 'updated' even when nothing changed.
|
|
|
|
test('FAILURE path (roadmap unavailable): Completed Phases / Progress are NOT marked updated — left-as-is is distinguishable from recomputed', () => {
|
|
const nullDeps = { clock: fixedClock, roadmapProvider: () => null };
|
|
const result = transitionCore(
|
|
completePhaseBody(),
|
|
{ kind: 'completePhase', phaseNum: '3', nextPhaseNum: '4', nextPhaseName: null, isLastPhase: false, planCount: 3, summaryCount: 3 },
|
|
nullDeps,
|
|
);
|
|
assert.ok(!result.updated.includes('Completed Phases'),
|
|
`left-as-is 'Completed Phases' must NOT appear in updated; got ${JSON.stringify(result.updated)}`);
|
|
assert.ok(!result.updated.includes('Progress'),
|
|
`left-as-is 'Progress' must NOT appear in updated; got ${JSON.stringify(result.updated)}`);
|
|
// Values are unchanged (the benign-preservation contract from the test above).
|
|
assert.strictEqual(stateExtractField(result.content, 'Completed Phases'), '2');
|
|
assert.strictEqual(stateExtractField(result.content, 'Progress'), '40%');
|
|
});
|
|
|
|
test('BENIGN path (roadmap recomputes a different value): Completed Phases / Progress ARE marked updated — recomputed is distinguishable from left-as-is', () => {
|
|
const result = transitionCore(
|
|
completePhaseBody(),
|
|
{ kind: 'completePhase', phaseNum: '3', nextPhaseNum: '4', nextPhaseName: null, isLastPhase: false, planCount: 3, summaryCount: 3 },
|
|
deps, // roadmapProvider => ROADMAP_3_OF_5, which recomputes Completed Phases 2 → 3
|
|
);
|
|
assert.ok(result.updated.includes('Completed Phases'),
|
|
`recomputed 'Completed Phases' must appear in updated; got ${JSON.stringify(result.updated)}`);
|
|
assert.ok(result.updated.includes('Progress'),
|
|
`recomputed 'Progress' must appear in updated; got ${JSON.stringify(result.updated)}`);
|
|
assert.strictEqual(stateExtractField(result.content, 'Completed Phases'), '3');
|
|
assert.strictEqual(stateExtractField(result.content, 'Progress'), '60%');
|
|
});
|
|
});
|
|
|
|
describe('ADR-1769 Phase 3: completePhase edge cases', () => {
|
|
const deps = { clock: fixedClock, roadmapProvider: () => ROADMAP_3_OF_5 };
|
|
|
|
test('falls back to the "Phase:" field when "Current Phase:" is absent (stateReplaceFieldWithFallback)', () => {
|
|
const input = [
|
|
'# Project State',
|
|
'',
|
|
'Phase: 3 of 5',
|
|
'**Status:** Executing Phase 3',
|
|
'**Last Activity:** 2026-06-20',
|
|
'**Completed Phases:** 2',
|
|
'**Total Phases:** 5',
|
|
'**Progress:** 40%',
|
|
'',
|
|
].join('\n');
|
|
const result = transitionCore(
|
|
input,
|
|
{ kind: 'completePhase', phaseNum: '3', nextPhaseNum: '4', nextPhaseName: null, isLastPhase: false, planCount: 3, summaryCount: 3 },
|
|
deps,
|
|
);
|
|
const phase = stateExtractField(result.content, 'Phase');
|
|
assert.ok(/^4 of 5/.test(phase || ''), `Phase should advance to "4 of 5"; got ${JSON.stringify(phase)}`);
|
|
});
|
|
|
|
test('updates body Status, not the YAML status key, when frontmatter is present (#1255)', () => {
|
|
const input = [
|
|
'---',
|
|
'status: executing',
|
|
'current_phase: "3"',
|
|
'---',
|
|
'',
|
|
'# Project State',
|
|
'',
|
|
'**Current Phase:** 3 of 5',
|
|
'**Status:** Executing Phase 3',
|
|
'**Last Activity:** 2026-06-20',
|
|
'**Completed Phases:** 2',
|
|
'**Total Phases:** 5',
|
|
'**Progress:** 40%',
|
|
'',
|
|
].join('\n');
|
|
const result = transitionCore(
|
|
input,
|
|
{ kind: 'completePhase', phaseNum: '3', nextPhaseNum: '4', nextPhaseName: null, isLastPhase: false, planCount: 3, summaryCount: 3 },
|
|
deps,
|
|
);
|
|
// Body Status line must read "Ready to plan".
|
|
const bodyStatus = stateExtractField(result.content, 'Status');
|
|
assert.strictEqual(bodyStatus, 'Ready to plan');
|
|
// Frontmatter must remain a block and keep its YAML keys (not be mangled).
|
|
assert.ok(/^---\r?\n[\s\S]*?\r?\n---/.test(result.content), 'frontmatter block must be preserved');
|
|
const fmLine = result.content.split('\n').find((l) => /^status:/.test(l));
|
|
assert.ok(fmLine && /executing/.test(fmLine), `YAML status key must be untouched; got ${JSON.stringify(fmLine)}`);
|
|
});
|
|
|
|
test('when nextPhaseName is absent and Current Phase had no "of total", value is the bare phase number', () => {
|
|
const input = [
|
|
'# Project State',
|
|
'',
|
|
'**Current Phase:** 3',
|
|
'**Status:** Executing Phase 3',
|
|
'**Last Activity:** 2026-06-20',
|
|
'**Completed Phases:** 2',
|
|
'**Total Phases:** 5',
|
|
'**Progress:** 40%',
|
|
'',
|
|
].join('\n');
|
|
const result = transitionCore(
|
|
input,
|
|
{ kind: 'completePhase', phaseNum: '3', nextPhaseNum: '4', nextPhaseName: null, isLastPhase: false, planCount: 3, summaryCount: 3 },
|
|
deps,
|
|
);
|
|
assert.strictEqual(stateExtractField(result.content, 'Current Phase'), '4');
|
|
});
|
|
});
|
|
|
|
// ADR-1769 Phase 4: plannedPhase + milestoneSwitch
|
|
|
|
function plannedPhaseBody() {
|
|
return [
|
|
'# Project State',
|
|
'',
|
|
'**Status:** Planning',
|
|
'**Total Plans in Phase:** 0',
|
|
'**Last Activity:** 2026-06-20',
|
|
'**Last Activity Description:** previous planning',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 3 (Test Phase) — EXECUTING',
|
|
'Plan: —',
|
|
'Status: Executing Phase 3',
|
|
'Last activity: 2026-06-20 — mid-flight',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
describe('ADR-1769 Phase 4: plannedPhase transition — body field updates', () => {
|
|
const deps = { clock: fixedClock };
|
|
|
|
test('Status advances to "Ready to execute" when the existing value is a template default (Planning)', () => {
|
|
const result = transitionCore(plannedPhaseBody(), { kind: 'plannedPhase', phaseNumber: 3, planCount: 4 }, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Status'), 'Ready to execute');
|
|
assert.ok(result.updated.includes('Status'));
|
|
});
|
|
|
|
test('Total Plans in Phase is set to planCount', () => {
|
|
const result = transitionCore(plannedPhaseBody(), { kind: 'plannedPhase', phaseNumber: 3, planCount: 4 }, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '4');
|
|
assert.ok(result.updated.includes('Total Plans in Phase'));
|
|
});
|
|
|
|
test('Last Activity is refreshed to clock.today() when the existing value is a date (template default)', () => {
|
|
const result = transitionCore(plannedPhaseBody(), { kind: 'plannedPhase', phaseNumber: 3, planCount: 4 }, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Last Activity'), '2026-06-27');
|
|
});
|
|
|
|
test('Last Activity Description carries the planning-complete narrative', () => {
|
|
const result = transitionCore(plannedPhaseBody(), { kind: 'plannedPhase', phaseNumber: 3, planCount: 4 }, deps);
|
|
assert.strictEqual(
|
|
stateExtractField(result.content, 'Last Activity Description'),
|
|
'Phase 3 planning complete — 4 plans ready',
|
|
);
|
|
});
|
|
|
|
test('Current Position Status + Last activity are updated', () => {
|
|
const result = transitionCore(plannedPhaseBody(), { kind: 'plannedPhase', phaseNumber: 3, planCount: 4 }, deps);
|
|
assert.ok(result.updated.includes('Current Position'),
|
|
`updated should include Current Position; got ${JSON.stringify(result.updated)}`);
|
|
// The Current Position section should now carry the planning-complete narrative.
|
|
assert.ok(/Phase 3 planning complete/.test(result.content));
|
|
});
|
|
|
|
test('executor-authored Status is preserved (Knuth invariant — non-template value not overwritten)', () => {
|
|
const custom = plannedPhaseBody().replace('**Status:** Planning', '**Status:** Awaiting human design review');
|
|
const result = transitionCore(custom, { kind: 'plannedPhase', phaseNumber: 3, planCount: 4 }, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Status'), 'Awaiting human design review');
|
|
assert.ok(!result.updated.includes('Status'),
|
|
`Status must not be in updated for an executor-authored value; got ${JSON.stringify(result.updated)}`);
|
|
});
|
|
|
|
test('planCount=null leaves Total Plans in Phase untouched', () => {
|
|
const result = transitionCore(plannedPhaseBody(), { kind: 'plannedPhase', phaseNumber: 3, planCount: null }, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '0');
|
|
assert.ok(!result.updated.includes('Total Plans in Phase'));
|
|
});
|
|
|
|
test('frontmatter is preserved and body Status (not YAML status) is updated (#1255)', () => {
|
|
const input = [
|
|
'---',
|
|
'status: planning',
|
|
'---',
|
|
'',
|
|
'# Project State',
|
|
'',
|
|
'**Status:** Planning',
|
|
'**Total Plans in Phase:** 0',
|
|
'**Last Activity:** 2026-06-20',
|
|
'**Last Activity Description:** prev',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Status: Executing Phase 3',
|
|
'Last activity: 2026-06-20 — mid',
|
|
'',
|
|
].join('\n');
|
|
const result = transitionCore(input, { kind: 'plannedPhase', phaseNumber: 3, planCount: 2 }, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Status'), 'Ready to execute');
|
|
assert.ok(/^---\r?\n[\s\S]*?\r?\n---/.test(result.content), 'frontmatter block preserved');
|
|
});
|
|
});
|
|
|
|
describe('ADR-1769 Phase 4: milestoneSwitch transition — milestone reset', () => {
|
|
const deps = { clock: fixedClock };
|
|
|
|
function milestoneBody() {
|
|
return [
|
|
'---',
|
|
'gsd_state_version: 1.0',
|
|
'milestone: v1.0',
|
|
'milestone_name: Old Milestone',
|
|
'status: executing',
|
|
'current_phase: "3"',
|
|
'progress:',
|
|
' total_phases: 5',
|
|
' completed_phases: 2',
|
|
' percent: 40',
|
|
'---',
|
|
'',
|
|
'# Project State',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 3 — EXECUTING',
|
|
'Plan: 2 of 5',
|
|
'Status: Executing Phase 3',
|
|
'Last activity: 2026-06-20 — mid-flight',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
test('frontmatter milestone + milestone_name are reset to the new version', () => {
|
|
const result = transitionCore(milestoneBody(), { kind: 'milestoneSwitch', version: 'v2.0', name: 'New Milestone' }, deps);
|
|
const fmLine = (key) => result.content.split('\n').find((l) => new RegExp(`^${key}:`).test(l));
|
|
assert.strictEqual(fmLine('milestone'), 'milestone: v2.0');
|
|
assert.strictEqual(fmLine('milestone_name'), 'milestone_name: New Milestone');
|
|
});
|
|
|
|
test('frontmatter status resets to planning and progress resets to zero', () => {
|
|
const result = transitionCore(milestoneBody(), { kind: 'milestoneSwitch', version: 'v2.0', name: 'New Milestone' }, deps);
|
|
assert.strictEqual(result.content.split('\n').find((l) => /^status:/.test(l)), 'status: planning');
|
|
assert.ok(/total_phases:\s*0/.test(result.content), 'total_phases should reset to 0');
|
|
assert.ok(/completed_phases:\s*0/.test(result.content), 'completed_phases should reset to 0');
|
|
assert.ok(/percent:\s*0/.test(result.content), 'percent should reset to 0');
|
|
});
|
|
|
|
test('gsd_state_version is preserved across the reset', () => {
|
|
const result = transitionCore(milestoneBody(), { kind: 'milestoneSwitch', version: 'v2.0', name: 'New Milestone' }, deps);
|
|
assert.ok(/gsd_state_version:\s*1\.0/.test(result.content), 'gsd_state_version must be preserved');
|
|
});
|
|
|
|
test('Current Position section is reset to "Not started (defining requirements)"', () => {
|
|
const result = transitionCore(milestoneBody(), { kind: 'milestoneSwitch', version: 'v2.0', name: 'New Milestone' }, deps);
|
|
assert.ok(/Phase: Not started \(defining requirements\)/.test(result.content));
|
|
assert.ok(/Status: Defining requirements/.test(result.content));
|
|
assert.ok(new RegExp(`Last activity: 2026-06-27 — Milestone v2.0 started`).test(result.content));
|
|
});
|
|
|
|
test('Accumulated Context / body content outside Current Position is preserved', () => {
|
|
const input = milestoneBody() +
|
|
'\n## Accumulated Context\n\n- An important decision we must keep.\n';
|
|
const result = transitionCore(input, { kind: 'milestoneSwitch', version: 'v2.0', name: 'New Milestone' }, deps);
|
|
assert.ok(/An important decision we must keep/.test(result.content),
|
|
'Accumulated Context must survive the milestone reset');
|
|
});
|
|
|
|
test('blank name falls back to the "milestone" placeholder', () => {
|
|
const result = transitionCore(milestoneBody(), { kind: 'milestoneSwitch', version: 'v2.0', name: '' }, deps);
|
|
assert.strictEqual(
|
|
result.content.split('\n').find((l) => /^milestone_name:/.test(l)),
|
|
'milestone_name: milestone',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ADR-1769 Phase 5: milestoneComplete
|
|
|
|
describe('ADR-1769 Phase 5: milestoneComplete transition — closure write', () => {
|
|
const deps = { clock: fixedClock };
|
|
const intent = { kind: 'milestoneComplete', version: 'v1.0', nextMilestoneCommand: '/gsd:new-milestone' };
|
|
|
|
function preCloseBody() {
|
|
return [
|
|
'# Project State',
|
|
'',
|
|
'**Status:** Executing Phase 5',
|
|
'**Last Activity:** 2026-06-20',
|
|
'**Last Activity Description:** mid-flight',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 5 — EXECUTING',
|
|
'Plan: 2 of 3',
|
|
'Status: Executing Phase 5',
|
|
'Last activity: 2026-06-20 — running',
|
|
'',
|
|
'## Operator Next Steps',
|
|
'',
|
|
'- Re-run /gsd:complete-milestone v1.0',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
test('Status becomes "<version> milestone complete"', () => {
|
|
const result = transitionCore(preCloseBody(), intent, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Status'), 'v1.0 milestone complete');
|
|
assert.ok(result.updated.includes('Status'));
|
|
});
|
|
|
|
test('Last Activity is refreshed to clock.today()', () => {
|
|
const result = transitionCore(preCloseBody(), intent, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Last Activity'), '2026-06-27');
|
|
});
|
|
|
|
test('Last Activity Description carries the archived narrative', () => {
|
|
const result = transitionCore(preCloseBody(), intent, deps);
|
|
assert.strictEqual(
|
|
stateExtractField(result.content, 'Last Activity Description'),
|
|
'v1.0 milestone completed and archived',
|
|
);
|
|
});
|
|
|
|
test('Current Position resets to "Awaiting next milestone" with archived narrative', () => {
|
|
const result = transitionCore(preCloseBody(), intent, deps);
|
|
assert.ok(/Phase: Milestone v1\.0 complete/.test(result.content));
|
|
assert.ok(/Status: Awaiting next milestone/.test(result.content));
|
|
assert.ok(/Last activity: 2026-06-27 — Milestone v1\.0 completed and archived/.test(result.content));
|
|
assert.ok(result.updated.includes('Current Position'));
|
|
});
|
|
|
|
test('Operator Next Steps is rewritten to point at the next-milestone command', () => {
|
|
const result = transitionCore(preCloseBody(), intent, deps);
|
|
assert.ok(/## Operator Next Steps/.test(result.content));
|
|
assert.ok(/- Start the next milestone with \/gsd:new-milestone/.test(result.content));
|
|
// The stale prior instruction must be gone.
|
|
assert.ok(!/Re-run \/gsd:complete-milestone/.test(result.content),
|
|
'stale Operator Next Steps tail must be replaced');
|
|
});
|
|
|
|
test('#2245 F7: CRLF blank line after a reset heading is preserved (byte-parity with LF)', () => {
|
|
// resetSectionVerbatim's post-heading blank-swallow loop recognised only
|
|
// a bare `\n` — on a CRLF document, a `\r\n` blank line right after the
|
|
// heading fell into the DISCARDED span instead of the kept prefix,
|
|
// silently dropping one blank line relative to the LF-equivalent output.
|
|
const lfResult = transitionCore(preCloseBody(), intent, deps);
|
|
const crlfResult = transitionCore(preCloseBody().replace(/\n/g, '\r\n'), intent, deps);
|
|
|
|
assert.strictEqual(
|
|
crlfResult.content.replace(/\r\n/g, '\n'),
|
|
lfResult.content,
|
|
'CRLF output, normalized back to LF, must match the LF output byte-for-byte',
|
|
);
|
|
});
|
|
|
|
test('Operator Next Steps section is inserted when absent', () => {
|
|
const input = [
|
|
'# Project State',
|
|
'',
|
|
'**Status:** Executing Phase 5',
|
|
'**Last Activity:** 2026-06-20',
|
|
'**Last Activity Description:** mid',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 5 — EXECUTING',
|
|
'Status: Executing Phase 5',
|
|
'',
|
|
].join('\n');
|
|
const result = transitionCore(input, intent, deps);
|
|
assert.ok(/## Operator Next Steps/.test(result.content));
|
|
assert.ok(/- Start the next milestone with \/gsd:new-milestone/.test(result.content));
|
|
});
|
|
|
|
test('Current Position section is inserted when absent', () => {
|
|
const input = '# Project State\n\n**Status:** Executing\n**Last Activity:** 2026-06-20\n';
|
|
const result = transitionCore(input, intent, deps);
|
|
assert.ok(/## Current Position/.test(result.content));
|
|
assert.ok(/Status: Awaiting next milestone/.test(result.content));
|
|
});
|
|
|
|
test('frontmatter is preserved across the closure write (#1255)', () => {
|
|
const input = [
|
|
'---',
|
|
'status: executing',
|
|
'milestone: v1.0',
|
|
'---',
|
|
'',
|
|
'# Project State',
|
|
'',
|
|
'**Status:** Executing Phase 5',
|
|
'**Last Activity:** 2026-06-20',
|
|
'**Last Activity Description:** mid',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 5 — EXECUTING',
|
|
'Status: Executing Phase 5',
|
|
'',
|
|
].join('\n');
|
|
const result = transitionCore(input, intent, deps);
|
|
// Body Status must be the closure value, not the YAML status key.
|
|
assert.strictEqual(stateExtractField(result.content, 'Status'), 'v1.0 milestone complete');
|
|
assert.ok(/^---\r?\n[\s\S]*?\r?\n---/.test(result.content), 'frontmatter block preserved');
|
|
assert.ok(/^milestone: v1\.0/m.test(result.content), 'frontmatter milestone preserved');
|
|
});
|
|
});
|
|
|
|
// ADR-1769 Phase 6: patch
|
|
|
|
describe('ADR-1769 Phase 6: patch transition — field updates', () => {
|
|
const deps = { clock: fixedClock };
|
|
|
|
test('applies each patched field and reports the updated set', () => {
|
|
const input = [
|
|
'# Project State',
|
|
'',
|
|
'**Status:** Planning',
|
|
'**Current Plan:** 2',
|
|
'**Total Plans in Phase:** 5',
|
|
'',
|
|
].join('\n');
|
|
const result = transitionCore(
|
|
input,
|
|
{ kind: 'patch', patches: { Status: 'Paused', 'Current Plan': '3' } },
|
|
deps,
|
|
);
|
|
assert.strictEqual(stateExtractField(result.content, 'Status'), 'Paused');
|
|
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '3');
|
|
assert.deepStrictEqual(result.data && result.data.updated, ['Status', 'Current Plan']);
|
|
});
|
|
|
|
test('reports failed fields (no matching field in content)', () => {
|
|
const input = '# Project State\n\n**Status:** Planning\n';
|
|
const result = transitionCore(
|
|
input,
|
|
{ kind: 'patch', patches: { Status: 'Paused', Nonexistent: 'x' } },
|
|
deps,
|
|
);
|
|
assert.deepStrictEqual(result.data && result.data.updated, ['Status']);
|
|
assert.deepStrictEqual(result.data && result.data.failed, ['Nonexistent']);
|
|
});
|
|
|
|
test('leaves content unchanged when no patch matches (no-op)', () => {
|
|
const input = '# Project State\n\n**Status:** Planning\n';
|
|
const result = transitionCore(input, { kind: 'patch', patches: { Nonexistent: 'x' } }, deps);
|
|
assert.strictEqual(result.content, input);
|
|
assert.deepStrictEqual(result.data && result.data.updated, []);
|
|
assert.deepStrictEqual(result.data && result.data.failed, ['Nonexistent']);
|
|
});
|
|
|
|
// ADR-3408 §8.3(b) / #3469: STALE as of this phase — patch now strips
|
|
// frontmatter FIRST (matching updateCore), so a lowercase frontmatter key
|
|
// like `stopped_at` can no longer reach the YAML block through this path.
|
|
// The old assertion here ("patch operates on the full content... a
|
|
// lowercase frontmatter key is matched and replaced") is the exact bypass
|
|
// ADR-3408 §8.3(b) closes: it let an arbitrary caller-supplied patch key
|
|
// rewrite YAML frontmatter outside FIELD_CLASSIFICATION, undetected by the
|
|
// write-seam guard's Axis 2 (every step called an owner). See the Matrix D
|
|
// section below for the corrected contract.
|
|
test('patching a frontmatter-shaped key no longer reaches the YAML line (ADR-3408 §8.3(b) / #3469)', () => {
|
|
const input = ['---', 'status: executing', 'stopped_at: 2026-01-01', '---', '', '# State', ''].join('\n');
|
|
const result = transitionCore(input, { kind: 'patch', patches: { stopped_at: '2026-06-27' } }, deps);
|
|
assert.ok(!/^stopped_at: 2026-06-27$/m.test(result.content), 'YAML stopped_at must NOT be patched directly');
|
|
assert.ok(/^stopped_at: 2026-01-01$/m.test(result.content), 'YAML stopped_at must survive unchanged');
|
|
assert.deepStrictEqual(result.data && result.data.updated, []);
|
|
assert.deepStrictEqual(result.data && result.data.failed, ['stopped_at']);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// ADR-3408 §8.3(b) Matrix D (#3469): patchCore strips frontmatter first,
|
|
// matching updateCore — closes Phase 1's declared known gap (a
|
|
// frontmatter-shaped patch key could rewrite the YAML block outside
|
|
// FIELD_CLASSIFICATION). Test matrix: .gsd/phase/refactor-3469-one-write-seam/50-test-matrix.md
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('ADR-3408 §8.3(b) Matrix D: patchCore strips frontmatter first (#3469)', () => {
|
|
const deps = { clock: fixedClock };
|
|
|
|
// D1: a frontmatter-shaped key (snake_case, no Title-Case body counterpart)
|
|
// can never match — patch is body-only now, so the write "routes through
|
|
// the seam": a frontmatter change can only happen via FIELD_CLASSIFICATION's
|
|
// own preservation/sync machinery, never via a direct patch bypass.
|
|
test('D1: a frontmatter-shaped key (current_phase) is reported failed, and the frontmatter is untouched', () => {
|
|
const input = [
|
|
'---',
|
|
'current_phase: "3"',
|
|
'---',
|
|
'',
|
|
'# State',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 3 (alpha)',
|
|
'',
|
|
].join('\n');
|
|
const result = transitionCore(input, { kind: 'patch', patches: { current_phase: '9' } }, deps);
|
|
assert.deepStrictEqual(result.data && result.data.updated, []);
|
|
assert.deepStrictEqual(result.data && result.data.failed, ['current_phase']);
|
|
assert.ok(/^current_phase: "3"$/m.test(result.content), 'frontmatter current_phase must be unchanged');
|
|
assert.strictEqual(result.content, input, 'no-op: content returned verbatim when nothing in the body matched');
|
|
});
|
|
|
|
// D2: a body-shaped key (Title-Case) is the legitimate case and is
|
|
// unaffected by the strip-first fix — it was always matched against the
|
|
// body, and still is.
|
|
test('D2: a body-shaped key (Status) still updates the body — the legitimate, unaffected case', () => {
|
|
const input = ['---', 'status: executing', '---', '', '# State', '', '**Status:** Planning', ''].join('\n');
|
|
const result = transitionCore(input, { kind: 'patch', patches: { Status: 'Paused' } }, deps);
|
|
assert.deepStrictEqual(result.data && result.data.updated, ['Status']);
|
|
assert.deepStrictEqual(result.data && result.data.failed, []);
|
|
assert.ok(result.content.includes('**Status:** Paused'), 'body Status must be updated');
|
|
assert.ok(/^status: executing$/m.test(result.content), 'frontmatter status is untouched by patchCore itself');
|
|
});
|
|
|
|
// D3 (boundary, extend #3351 variants A/B): a display-cased key matching
|
|
// the body field succeeds; the SAME field's lower-cased/frontmatter-shaped
|
|
// spelling fails — same content, two spellings, two outcomes.
|
|
test('D3: display-cased "Current Phase" succeeds; lower-cased "current_phase" fails, on the same content', () => {
|
|
const input = [
|
|
'---',
|
|
'current_phase: "1"',
|
|
'---',
|
|
'',
|
|
'# State',
|
|
'',
|
|
'**Current Phase:** 1',
|
|
'',
|
|
].join('\n');
|
|
const displayCased = transitionCore(input, { kind: 'patch', patches: { 'Current Phase': '2' } }, deps);
|
|
assert.deepStrictEqual(displayCased.data && displayCased.data.updated, ['Current Phase']);
|
|
assert.ok(displayCased.content.includes('**Current Phase:** 2'));
|
|
|
|
const lowerCased = transitionCore(input, { kind: 'patch', patches: { current_phase: '2' } }, deps);
|
|
assert.deepStrictEqual(lowerCased.data && lowerCased.data.updated, []);
|
|
assert.deepStrictEqual(lowerCased.data && lowerCased.data.failed, ['current_phase']);
|
|
});
|
|
|
|
// D4 (hostile): a key that is simultaneously frontmatter-shaped AND
|
|
// case-insensitively matches a body field (stateReplaceField's `^field:`
|
|
// pattern is case-insensitive) — the body is the ONE deterministic winner,
|
|
// asserted explicitly, because frontmatter is stripped out of the matching
|
|
// surface before any pattern ever runs.
|
|
test('D4: a key matching both a frontmatter key and a body field — body wins deterministically, frontmatter inert', () => {
|
|
const input = ['---', 'status: executing', '---', '', '# State', '', '**Status:** In progress', ''].join('\n');
|
|
const result = transitionCore(input, { kind: 'patch', patches: { status: 'Aborted' } }, deps);
|
|
assert.deepStrictEqual(result.data && result.data.updated, ['status']);
|
|
assert.ok(result.content.includes('**Status:** Aborted'), 'body Status must be the one that changed');
|
|
assert.ok(/^status: executing$/m.test(result.content), 'frontmatter status key must never be touched by patchCore');
|
|
});
|
|
|
|
// D5 (boundary, extend): an empty patch is a true no-op — no write, both
|
|
// report arrays empty.
|
|
test('D5: an empty patch {} is a no-op — content returned verbatim, both arrays empty', () => {
|
|
const input = '# State\n\n**Status:** Planning\n';
|
|
const result = transitionCore(input, { kind: 'patch', patches: {} }, deps);
|
|
assert.strictEqual(result.content, input);
|
|
assert.deepStrictEqual(result.data && result.data.updated, []);
|
|
assert.deepStrictEqual(result.data && result.data.failed, []);
|
|
});
|
|
|
|
// D6 (hostile): __proto__ / constructor as patch keys must not pollute
|
|
// Object.prototype. `intent.patches` is built via JSON.parse (the shape a
|
|
// real `state.patch` JSON payload takes) specifically because object-
|
|
// literal syntax special-cases `__proto__` — JSON.parse does not, and is
|
|
// the classic prototype-pollution vector this test must exercise for real.
|
|
test('D6: __proto__ / constructor patch keys do not pollute Object.prototype', () => {
|
|
const patches = JSON.parse('{"__proto__":"evil","constructor":"evil2"}');
|
|
const input = '# State\n\n**Status:** Planning\n';
|
|
const result = transitionCore(input, { kind: 'patch', patches }, deps);
|
|
|
|
// Object.prototype itself must be untouched.
|
|
assert.strictEqual(Object.getPrototypeOf({}), Object.prototype);
|
|
assert.strictEqual(({}).polluted, undefined);
|
|
assert.strictEqual(typeof ({}).constructor, 'function');
|
|
|
|
// Behaves like any other unmatched field — no body line named
|
|
// `__proto__` or `constructor` exists, so both are reported failed.
|
|
assert.deepStrictEqual((result.data && result.data.updated) || [], []);
|
|
assert.deepStrictEqual((result.data && result.data.failed || []).sort(), ['__proto__', 'constructor']);
|
|
assert.strictEqual(result.content, input);
|
|
});
|
|
|
|
// D7 (independence, extend): updateCore strips frontmatter first — the correct
|
|
// shape patchCore now matches — so a frontmatter-shaped `field` cannot reach
|
|
// the YAML block by text replacement.
|
|
//
|
|
// NARROWED BY #3699, deliberately. The original assertion was "a
|
|
// frontmatter-shaped field can NEVER reach the YAML block", which was a true
|
|
// characterisation of updateCore when this test was written as an independence
|
|
// guard for #3469 — but it is broader than the rule ADR-3408 actually states.
|
|
// §8.3(b)'s invariant is "no transition core calls `stateReplaceField` on
|
|
// unstripped content" (ADR-3408 line 318), and #3699's repair path honours it:
|
|
// it strips frontmatter, edits the PARSED object, and re-serialises via
|
|
// `reconstructFrontmatter` — it never runs the body-field text replacer over
|
|
// YAML, which is the dangerous shape the rule exists to forbid.
|
|
//
|
|
// So the invariant is re-pinned here at the ADR's actual boundary, in both
|
|
// directions, rather than deleted.
|
|
test('D7: a frontmatter-shaped field cannot reach the YAML block while a body source exists', () => {
|
|
// The body carries `Current Phase`, so the body IS the writable route and
|
|
// the frontmatter key must be refused exactly as before.
|
|
const input = [
|
|
'---', 'current_phase: "3"', '---', '',
|
|
'# State', '', '**Current Phase:** 3', '**Status:** Planning', '',
|
|
].join('\n');
|
|
const result = transitionCore(input, { kind: 'update', field: 'current_phase', value: '9' }, deps);
|
|
assert.strictEqual(result.content, input, 'no write may occur through the frontmatter key');
|
|
assert.strictEqual(result.data && result.data.updated, false);
|
|
assert.ok(/^current_phase: "3"$/m.test(result.content));
|
|
});
|
|
|
|
test('D7b: the #3699 repair path is the ONLY way frontmatter is written, and it never text-replaces over YAML', () => {
|
|
// Body source absent — the case-D repair shape. The write is permitted here,
|
|
// and `updated` is the field name rather than `false`.
|
|
const input = ['---', 'current_phase: "3"', '---', '', '# State', '', '**Status:** Planning', ''].join('\n');
|
|
const result = transitionCore(input, { kind: 'update', field: 'current_phase', value: '9' }, deps);
|
|
|
|
assert.strictEqual(result.data && result.data.updated, true);
|
|
assert.strictEqual(result.data && result.data.wroteFrontmatter, true, 'the repair path must announce itself');
|
|
assert.ok(/^current_phase: 9$/m.test(result.content), 'the frontmatter key carries the new value');
|
|
|
|
// ADR-3408 §8.3(b) still holds: the body is untouched and the frontmatter
|
|
// block was REBUILT from the parsed object, not text-patched in place. A
|
|
// `stateReplaceField` pass over unstripped content would have left the rest
|
|
// of the document's frontmatter formatting alone; re-serialisation is what
|
|
// proves the parsed-object route was taken.
|
|
assert.ok(result.content.includes('**Status:** Planning'), 'the body must be untouched');
|
|
assert.ok(!/current_phase: "9"/.test(result.content), 'the value went through the YAML serialiser, not a text splice');
|
|
});
|
|
});
|
|
|
|
// ADR-1769 Phase 7: update, prune, sync
|
|
|
|
describe('ADR-1769 Phase 7: update transition — single body field', () => {
|
|
const deps = { clock: fixedClock };
|
|
|
|
test('replaces a body field and reports updated:true', () => {
|
|
const input = '# Project State\n\n**Status:** Planning\n**Current Plan:** 2\n';
|
|
const result = transitionCore(input, { kind: 'update', field: 'Current Plan', value: '3' }, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '3');
|
|
assert.strictEqual(result.data && result.data.updated, true);
|
|
});
|
|
|
|
test('reports updated:false when the field is absent', () => {
|
|
const input = '# Project State\n\n**Status:** Planning\n';
|
|
const result = transitionCore(input, { kind: 'update', field: 'Nonexistent', value: 'x' }, deps);
|
|
assert.strictEqual(result.content, input);
|
|
assert.strictEqual(result.data && result.data.updated, false);
|
|
});
|
|
|
|
test('preserves frontmatter across the body update', () => {
|
|
const input = ['---', 'status: planning', '---', '', '# State', '', '**Status:** Planning', ''].join('\n');
|
|
const result = transitionCore(input, { kind: 'update', field: 'Status', value: 'Paused' }, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Status'), 'Paused');
|
|
assert.ok(/^---\r?\n[\s\S]*?\r?\n---/.test(result.content));
|
|
});
|
|
});
|
|
|
|
describe('ADR-1769 Phase 7: prune transition — section pruning', () => {
|
|
const deps = { clock: fixedClock };
|
|
|
|
test('archives Decisions entries at or below the cutoff phase', () => {
|
|
const input = [
|
|
'# Session State',
|
|
'',
|
|
'## Decisions',
|
|
'',
|
|
'- [Phase 1]: Old',
|
|
'- [Phase 3]: Older',
|
|
'- [Phase 9]: Recent',
|
|
'',
|
|
].join('\n');
|
|
const result = transitionCore(input, { kind: 'prune', cutoff: 7 }, deps);
|
|
const archived = (result.data && result.data.archivedSections) || [];
|
|
assert.strictEqual(result.content.includes('[Phase 1]: Old'), false);
|
|
assert.strictEqual(result.content.includes('[Phase 3]: Older'), false);
|
|
assert.ok(result.content.includes('[Phase 9]: Recent'));
|
|
const decisions = archived.find((s) => s.section === 'Decisions');
|
|
assert.ok(decisions, 'Decisions archive entry must exist');
|
|
assert.strictEqual(decisions.count, 2);
|
|
});
|
|
|
|
test('archives Performance Metrics table rows at or below the cutoff', () => {
|
|
const input = [
|
|
'# State',
|
|
'',
|
|
'## Performance Metrics',
|
|
'',
|
|
'| Phase | Plans | Total | Avg/Plan |',
|
|
'| --- | --- | --- | --- |',
|
|
'| 1 | 4 | 8 | 2 |',
|
|
'| 9 | 2 | 4 | 2 |',
|
|
'',
|
|
].join('\n');
|
|
const result = transitionCore(input, { kind: 'prune', cutoff: 7 }, deps);
|
|
assert.ok(result.content.includes('| 9 | 2 | 4 | 2 |'), 'phase-9 row must remain');
|
|
assert.strictEqual(result.content.includes('| 1 | 4 | 8 | 2 |'), false, 'phase-1 row must be archived');
|
|
assert.ok(result.content.includes('| Phase | Plans |'), 'header row preserved');
|
|
});
|
|
|
|
test('no-op when nothing is old enough (totalPruned === 0)', () => {
|
|
const input = '# State\n\n## Decisions\n\n- [Phase 9]: Recent\n';
|
|
const result = transitionCore(input, { kind: 'prune', cutoff: 7 }, deps);
|
|
assert.strictEqual(result.content, input);
|
|
assert.strictEqual((result.data && result.data.totalPruned) || 0, 0);
|
|
});
|
|
});
|
|
|
|
describe('ADR-1769 Phase 7: sync transition — body writes + #1761', () => {
|
|
const deps = { clock: fixedClock };
|
|
|
|
test('updates Total Plans in Phase + Progress bar + Last Activity when bounded', () => {
|
|
const input = [
|
|
'# Project State',
|
|
'',
|
|
'**Total Plans in Phase:** 2',
|
|
'**Last Activity:** 2026-06-20',
|
|
'**Progress:** [████░░░░░░] 40%',
|
|
'',
|
|
].join('\n');
|
|
const result = transitionCore(
|
|
input,
|
|
{ kind: 'sync', totalPlansInPhase: 5, percent: 60 },
|
|
deps,
|
|
);
|
|
assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '5');
|
|
assert.strictEqual(stateExtractField(result.content, 'Last Activity'), '2026-06-27');
|
|
assert.ok(/\[██████░░░░\] 60%/.test(result.content), 'Progress bar must be 60%');
|
|
});
|
|
|
|
test('#1761: leaves Progress untouched when percent is null (milestone unbounded)', () => {
|
|
const input = [
|
|
'# Project State',
|
|
'',
|
|
'**Total Plans in Phase:** 2',
|
|
'**Last Activity:** 2026-06-20',
|
|
'**Progress:** [█████░░░░░] 50%',
|
|
'',
|
|
].join('\n');
|
|
const result = transitionCore(
|
|
input,
|
|
{ kind: 'sync', totalPlansInPhase: 5, percent: null },
|
|
deps,
|
|
);
|
|
// Total Plans + Last Activity still advance; Progress bar is left untouched.
|
|
assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '5');
|
|
assert.ok(/\[█████░░░░░\] 50%/.test(result.content), 'Progress bar must be unchanged when percent is null');
|
|
});
|
|
|
|
test('skips Total Plans write when totalPlansInPhase is null', () => {
|
|
const input = '# Project State\n\n**Total Plans in Phase:** 2\n**Progress:** 40%\n';
|
|
const result = transitionCore(input, { kind: 'sync', totalPlansInPhase: null, percent: null }, deps);
|
|
assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '2');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// ADR-1769 #1796: applyStatePreservation — table-driven post-sync consolidation
|
|
//
|
|
// Path A ("finish the consolidation"): the post-sync preservation block that
|
|
// lived inline in readModifyWriteStateMd (state.cts) is absorbed into the
|
|
// Transition Module as a pure, field-classification-table-driven function.
|
|
// Every preserved field (progress, status, stopped_at, current_phase_name) is
|
|
// governed by its FIELD_CLASSIFICATION row — one policy source, not three
|
|
// drifting encodings. Behavior is identical to the pre-consolidation block;
|
|
// these tests pin the table-driven contract. See issue #1796.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('ADR-1769 #1796: applyStatePreservation — table-driven post-sync consolidation', () => {
|
|
// Shared no-op deltas for tests that only exercise one field. #3468 folded
|
|
// the three dedicated status/stopped_at/current_phase_name parameter pairs
|
|
// into the single bodyDeltas channel every preserve-when-unchanged row now
|
|
// uses — `neutralBodyDeltas()` (defined below, hoisted) wires every such
|
|
// row to an "unchanged this write" delta so a test exercising ONE field
|
|
// (e.g. progress) never trips the §8.2 unwired-row throw for another.
|
|
const untouched = { bodyDeltas: neutralBodyDeltas() };
|
|
|
|
test('progress: restores curated block when table=preserve-always and transition is not re-deriving (!resync)', () => {
|
|
// Default behavior: wholesale curated restore. #3242 Bug A protection.
|
|
const curated = { progress: { total_phases: 4, completed_phases: 3, percent: 75 } };
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: curated,
|
|
resync: false,
|
|
...untouched,
|
|
}),
|
|
postFm: { progress: { total_phases: 5, completed_phases: 0, percent: 0 } } // disk-derived clobber,
|
|
});
|
|
assert.deepEqual(r.postFm.progress, { total_phases: 4, completed_phases: 3, percent: 75 });
|
|
assert.equal(r.mutated, true);
|
|
});
|
|
|
|
test('#2440: deriveProgressKeys=true — total_plans takes derived value under !resync', () => {
|
|
// The cmdStatePlannedPhase caller opts in via deriveProgressKeys. total_plans
|
|
// and total_phases take the derived (post-sync) value; completed_plans and
|
|
// completed_phases keep curated protection.
|
|
const curated = { progress: { total_plans: 50, completed_plans: 50, total_phases: 2, completed_phases: 1, percent: 100 } };
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: curated,
|
|
resync: false,
|
|
deriveProgressKeys: true,
|
|
...untouched,
|
|
}),
|
|
postFm: { progress: { total_plans: 64, completed_plans: 49, total_phases: 2, completed_phases: 1, percent: 77 } },
|
|
});
|
|
assert.equal(r.postFm.progress.total_plans, 64,
|
|
'total_plans must take derived value (64) when deriveProgressKeys=true (#2440)');
|
|
assert.equal(r.postFm.progress.completed_plans, 50,
|
|
'completed_plans must keep curated value (50 > 49 triggers ratchet)');
|
|
assert.equal(r.postFm.progress.total_phases, 2,
|
|
'total_phases takes derived value (same as curated here — identity)');
|
|
assert.equal(r.mutated, true);
|
|
});
|
|
|
|
test('#2440 boundary: deriveProgressKeys=true, total_plans derived == curated → identity', () => {
|
|
const curated = { progress: { total_plans: 64, completed_plans: 49 } };
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: curated,
|
|
resync: false,
|
|
deriveProgressKeys: true,
|
|
...untouched,
|
|
}),
|
|
postFm: { progress: { total_plans: 64, completed_plans: 49, percent: 77 } },
|
|
});
|
|
assert.equal(r.postFm.progress.total_plans, 64,
|
|
'total_plans equality → derived value (identity)');
|
|
});
|
|
|
|
test('#2969: deriveProgressKeys=true — completed_plans ratchets UP when disk count exceeds curated (gap-closure plans completed)', () => {
|
|
// Gap-closure scenario: a phase had 50 plans all summarized (completed_plans: 50),
|
|
// then 4 gap-closure plans were added (total_plans -> 54) and all 4 got SUMMARYs.
|
|
// Disk scan now counts 54 summaries. The curated completed_plans (50) must
|
|
// ratchet UP to the derived value (54), not stay pinned at 50 — otherwise
|
|
// completed_plans < total_plans forever even though every plan is summarized.
|
|
const curated = { progress: { total_plans: 54, completed_plans: 50, total_phases: 2, completed_phases: 1, percent: 93 } };
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: curated,
|
|
resync: false,
|
|
deriveProgressKeys: true,
|
|
...untouched,
|
|
}),
|
|
postFm: { progress: { total_plans: 54, completed_plans: 54, total_phases: 2, completed_phases: 1, percent: 100 } },
|
|
});
|
|
assert.equal(r.postFm.progress.total_plans, 54, 'total_plans takes derived value');
|
|
assert.equal(r.postFm.progress.completed_plans, 54,
|
|
'completed_plans must ratchet UP to derived (54 > curated 50 — gap-closure plans completed) (#2969)');
|
|
assert.equal(r.postFm.progress.percent, 100,
|
|
'percent must reflect the true completion fraction (54/54) (#2969)');
|
|
});
|
|
|
|
test('#2969 ratchet-down protection: deriveProgressKeys=true keeps curated when disk count < curated', () => {
|
|
// The ratchet must only go UP. If the disk count is somehow LOWER than
|
|
// curated (e.g. a SUMMARY was deleted), keep the curated value — do not
|
|
// derive downward. (#3242 curated-progress protection, scoped to deriveProgressKeys.)
|
|
const curated = { progress: { total_plans: 54, completed_plans: 50, percent: 93 } };
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: curated,
|
|
resync: false,
|
|
deriveProgressKeys: true,
|
|
...untouched,
|
|
}),
|
|
postFm: { progress: { total_plans: 54, completed_plans: 47, percent: 87 } },
|
|
});
|
|
assert.equal(r.postFm.progress.completed_plans, 50,
|
|
'completed_plans must NOT derive downward (47 < curated 50) — ratchet-up only (#2969/#3242)');
|
|
});
|
|
|
|
test('#2969 body-only write protection: deriveProgressKeys absent keeps wholesale restore', () => {
|
|
// state.update/patch (no deriveProgressKeys flag) must keep the full #3242
|
|
// wholesale curated restore — completed_plans never moves for a body-only edit.
|
|
const curated = { progress: { total_plans: 54, completed_plans: 50, percent: 93 } };
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: curated,
|
|
resync: false, // deriveProgressKeys NOT set — body-only write path
|
|
...untouched,
|
|
}),
|
|
postFm: { progress: { total_plans: 54, completed_plans: 54, percent: 100 } },
|
|
});
|
|
assert.equal(r.postFm.progress.completed_plans, 50,
|
|
'body-only write must keep curated completed_plans (no deriveProgressKeys) (#2969/#3242)');
|
|
});
|
|
|
|
test('progress: NOT restored when transition re-derives from disk (resync=true) — sync/advancePlan/completePhase path', () => {
|
|
const recomputed = { progress: { total_phases: 5, completed_phases: 1, percent: 20 } };
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: {},
|
|
resync: true,
|
|
...untouched,
|
|
}),
|
|
postFm: { ...recomputed },
|
|
});
|
|
assert.deepEqual(r.postFm.progress, { total_phases: 5, completed_phases: 1, percent: 20 });
|
|
assert.equal(r.mutated, false);
|
|
});
|
|
|
|
test('status: preserves when body Status source is unchanged (preserve-when-unchanged) and snapshot holds a real status', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { status: 'completed' },
|
|
resync: true,
|
|
bodyDeltas: { ...neutralBodyDeltas(), status: { pre: 'Executing Phase 3', post: 'Executing Phase 3' } },
|
|
}),
|
|
postFm: { status: 'verifying' },
|
|
});
|
|
assert.equal(r.postFm.status, 'completed');
|
|
assert.equal(r.mutated, true);
|
|
});
|
|
|
|
test('status: does NOT preserve when the body Status source line changed this write', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { status: 'completed' },
|
|
resync: true,
|
|
bodyDeltas: { ...neutralBodyDeltas(), status: { pre: 'Executing Phase 3', post: 'Completed Phase 3' } },
|
|
}),
|
|
postFm: { status: 'verifying' },
|
|
});
|
|
assert.equal(r.postFm.status, 'verifying');
|
|
assert.equal(r.mutated, false);
|
|
});
|
|
|
|
test('current_phase_name: preserves curated value when body Phase source unchanged (preserve-when-unchanged, #3468 reclassified)', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { current_phase_name: 'curated-name' },
|
|
resync: true,
|
|
bodyDeltas: { ...neutralBodyDeltas(), current_phase_name: { pre: '3', post: '3' } },
|
|
}),
|
|
postFm: { current_phase_name: 'wrong-parenthetical-harvest' },
|
|
});
|
|
assert.equal(r.postFm.current_phase_name, 'curated-name');
|
|
assert.equal(r.mutated, true);
|
|
});
|
|
|
|
test('returns mutated=false and untouched postFm when no preservation rule applies', () => {
|
|
const postFm = { status: 'executing', progress: { percent: 10 } };
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: {},
|
|
resync: true,
|
|
...untouched,
|
|
}),
|
|
postFm: postFm,
|
|
});
|
|
assert.equal(r.mutated, false);
|
|
assert.deepEqual(r.postFm, { status: 'executing', progress: { percent: 10 } });
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// #3258: every FIELD_CLASSIFICATION row declaring a preservation policy must be
|
|
// honored by applyStatePreservation (the table-consuming pass). The table's own
|
|
// docstring promises "a policy change is a one-row table edit" — this invariant
|
|
// proves it: for every non-`derive` row, a minimal input where the
|
|
// declared policy would restore the snapshot value DOES restore it. Adding a
|
|
// new preservation row without an implementation branch makes this fail.
|
|
//
|
|
// Written FIRST and RED before the fix. Before the fix this fails for six rows:
|
|
// last_activity_desc, paused_at, current_phase, current_plan (preserve-when-
|
|
// unchanged, only approximated by the weaker #905 absent-fallback) and
|
|
// milestone, milestone_name (preserve-if-placeholder, enforced only by the
|
|
// #948/#2135 guard in syncStateFrontmatter). See issue #3258.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#3258: applyStatePreservation honors every declared preservation row', () => {
|
|
// Universal "body source unchanged this write" deltas. Every
|
|
// preserve-when-unchanged probe reuses these so the delta condition (pre ===
|
|
// post) is satisfied and the only variable is whether the branch exists.
|
|
// #3468: current_phase_name folded into this same bodyDeltas channel
|
|
// (reclassified from preserve-always to preserve-when-unchanged) — every
|
|
// preserve-when-unchanged row must be present here or the §8.2 throw fires
|
|
// for whichever row a probe does not itself supply.
|
|
const SAME = { pre: 'unchanged-source', post: 'unchanged-source' };
|
|
const unchangedBodyDeltas = {
|
|
status: SAME,
|
|
stopped_at: SAME,
|
|
current_phase_name: SAME,
|
|
paused_at: SAME,
|
|
current_phase: SAME,
|
|
current_plan: SAME,
|
|
last_activity_desc: SAME,
|
|
};
|
|
|
|
// Per-policy probe. Returns whether applyStatePreservation restored the
|
|
// field's snapshot value under an input crafted so the declared policy fires.
|
|
function honored(field) {
|
|
const cls = getFieldClassification(field);
|
|
if (!cls) return false;
|
|
const policy = cls.preservation;
|
|
if (policy === 'derive') return true; // not a preservation policy
|
|
|
|
const GOOD = 'preserved-by-table';
|
|
const BAD = 'clobbered-by-derive';
|
|
|
|
if (policy === 'preserve-always') {
|
|
// Only `progress` carries this policy today (current_phase_name was
|
|
// reclassified to preserve-when-unchanged in #3468 — ADR-3408 §8.1).
|
|
const curated = { progress: { total_phases: 4, completed_phases: 3, percent: 75 } };
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: curated,
|
|
resync: false,
|
|
bodyDeltas: unchangedBodyDeltas,
|
|
}),
|
|
postFm: { progress: { total_phases: 5, completed_phases: 0, percent: 0 } },
|
|
});
|
|
return JSON.stringify(r.postFm.progress) === JSON.stringify(curated.progress);
|
|
}
|
|
|
|
if (policy === 'preserve-when-unchanged') {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { [field]: GOOD },
|
|
resync: true,
|
|
bodyDeltas: unchangedBodyDeltas,
|
|
}),
|
|
postFm: { [field]: BAD },
|
|
});
|
|
return r.postFm[field] === GOOD;
|
|
}
|
|
|
|
if (policy === 'preserve-if-placeholder') {
|
|
// Derived name is the placeholder 'milestone'; snapshot holds a real
|
|
// name+version. Mirrors the #948/#2135 contract: name restored to the
|
|
// curated snapshot, version restored alongside it.
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { milestone: GOOD, milestone_name: GOOD },
|
|
resync: true,
|
|
bodyDeltas: unchangedBodyDeltas,
|
|
}),
|
|
postFm: { milestone: 'derived-version', milestone_name: 'milestone' },
|
|
});
|
|
return r.postFm[field] === GOOD;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
test('every non-derive preservation row is honored (the one-row-table-edit contract)', () => {
|
|
const expected = [];
|
|
for (const [field, cls] of Object.entries(FIELD_CLASSIFICATION)) {
|
|
if (cls.preservation !== 'derive') {
|
|
expected.push(field);
|
|
}
|
|
}
|
|
const missing = expected.filter((f) => !honored(f));
|
|
assert.deepEqual(
|
|
missing,
|
|
[],
|
|
`applyStatePreservation does not honor these declared preservation rows (expected every ` +
|
|
`non-derive row to restore its snapshot value): ${JSON.stringify(missing)}. ` +
|
|
`Add a branch per ADR-1769 §4 so the table is the single policy source (#3258).`,
|
|
);
|
|
});
|
|
|
|
const lastActivityDescChangedDeltas = {
|
|
status: { pre: 'x', post: 'x' },
|
|
stopped_at: { pre: 'x', post: 'x' },
|
|
current_phase_name: { pre: 'x', post: 'x' },
|
|
paused_at: { pre: 'x', post: 'x' },
|
|
current_phase: { pre: 'x', post: 'x' },
|
|
current_plan: { pre: 'x', post: 'x' },
|
|
last_activity_desc: { pre: 'old description', post: 'new description from transition' }, // changed
|
|
};
|
|
|
|
test('a preserve-when-unchanged row with no wired bodyDeltas entry THROWS (ADR-3408 §8.2 — #3468 tightened from the pre-#3468 silent skip)', () => {
|
|
// Pre-#3468 this row was a "sentinel" proving a missing implementation was
|
|
// merely NOT restored (a silent `continue`). ADR-3408 §8.2 requires the
|
|
// stronger invariant: an internal invariant violation — a declared row
|
|
// the caller forgot to wire via bodyDeltas — THROWS with a structured
|
|
// error, never a silent no-op indistinguishable from a correct skip.
|
|
assert.throws(
|
|
() => applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { current_plan: 'preserved-by-table' },
|
|
resync: true,
|
|
bodyDeltas: {},
|
|
}),
|
|
postFm: { current_plan: 'derived' },
|
|
}),
|
|
(err) => {
|
|
assert.strictEqual(err.code, 'STATE_PRESERVATION_UNWIRED_ROW');
|
|
assert.strictEqual(err.field, 'current_phase',
|
|
'current_phase is the first preserve-when-unchanged field in FIELD_CLASSIFICATION\'s ' +
|
|
'iteration order, so it is the field named by the throw when bodyDeltas is empty');
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
// Per-field restore tests (clearer failure messages than the set-equality
|
|
// invariant alone, and they document each row's declared semantics).
|
|
|
|
test('last_activity_desc: preserve-when-unchanged restores snapshot when body source unchanged', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { last_activity_desc: 'authoritative description' },
|
|
resync: true,
|
|
bodyDeltas: { ...unchangedBodyDeltas },
|
|
}),
|
|
postFm: { last_activity_desc: 'stale derived description' },
|
|
});
|
|
assert.equal(r.postFm.last_activity_desc, 'authoritative description');
|
|
assert.equal(r.mutated, true);
|
|
});
|
|
|
|
test('last_activity_desc: derived wins when the body source changed this write (no over-preservation)', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { last_activity_desc: 'old description' },
|
|
resync: true,
|
|
bodyDeltas: { ...lastActivityDescChangedDeltas },
|
|
}),
|
|
postFm: { last_activity_desc: 'new description from transition' },
|
|
});
|
|
assert.equal(r.postFm.last_activity_desc, 'new description from transition');
|
|
assert.equal(r.mutated, false);
|
|
});
|
|
|
|
test('paused_at: preserve-when-unchanged restores curated value over a stale-but-present derived value', () => {
|
|
// Group 2: the declared #1230 delta heuristic beats the weaker #905
|
|
// absent-fallback. Derived is PRESENT but stale; body source unchanged →
|
|
// curated frontmatter value wins.
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { paused_at: '2026-02-02' },
|
|
resync: true,
|
|
bodyDeltas: { ...unchangedBodyDeltas },
|
|
}),
|
|
postFm: { paused_at: '2026-01-01' },
|
|
});
|
|
assert.equal(r.postFm.paused_at, '2026-02-02');
|
|
assert.equal(r.mutated, true);
|
|
});
|
|
|
|
test('current_phase: preserve-when-unchanged restores curated value over a stale derived value', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { current_phase: '4' },
|
|
resync: true,
|
|
bodyDeltas: { ...unchangedBodyDeltas },
|
|
}),
|
|
postFm: { current_phase: '2' },
|
|
});
|
|
assert.equal(r.postFm.current_phase, '4');
|
|
assert.equal(r.mutated, true);
|
|
});
|
|
|
|
test('current_plan: preserve-when-unchanged restores curated value over a stale derived value', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { current_plan: '5' },
|
|
resync: true,
|
|
bodyDeltas: { ...unchangedBodyDeltas },
|
|
}),
|
|
postFm: { current_plan: '3' },
|
|
});
|
|
assert.equal(r.postFm.current_plan, '5');
|
|
assert.equal(r.mutated, true);
|
|
});
|
|
|
|
test('milestone / milestone_name: preserve-if-placeholder restores curated name when derived is placeholder', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { milestone: '0.1', milestone_name: 'Real Curated Name' },
|
|
resync: true,
|
|
bodyDeltas: { ...unchangedBodyDeltas },
|
|
}),
|
|
postFm: { milestone: '0.x', milestone_name: 'milestone' } // placeholder derive,
|
|
});
|
|
assert.equal(r.postFm.milestone_name, 'Real Curated Name',
|
|
'placeholder-derived milestone_name must yield to the curated snapshot (#948/#2135 contract)');
|
|
assert.equal(r.postFm.milestone, '0.1',
|
|
'milestone version must stay consistent with the preserved name');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// #3468 (ADR-3408, Phase 1 test matrix 50-test-matrix.md, sections A/B/C):
|
|
// policy dispatch, the §8.2 unenforced-row invariant, and behavior identity
|
|
// across the refactor. Failing-first: several rows below are EXPECTED to
|
|
// fail against the current tree until the refactor lands (see each test's
|
|
// comment for which). Everything else characterizes behavior the refactor
|
|
// must preserve byte-for-byte.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// Wires every currently-declared preserve-when-unchanged field with a neutral
|
|
// "unchanged this write" delta, so a test probing ONE field never trips over
|
|
// another field being unwired — forward-compatible with both today's tree
|
|
// (where an unwired row silently `continue`s) and the post-refactor tree
|
|
// (where an unwired row throws per §8.2 / matrix row B1).
|
|
function neutralBodyDeltas() {
|
|
const deltas = {};
|
|
for (const [field, cls] of Object.entries(FIELD_CLASSIFICATION)) {
|
|
if (cls.preservation === 'preserve-when-unchanged') {
|
|
deltas[field] = { pre: 'unchanged-source', post: 'unchanged-source' };
|
|
}
|
|
}
|
|
return deltas;
|
|
}
|
|
|
|
// #3468 folded status/stopped_at/current_phase_name's three dedicated
|
|
// pre/post parameter pairs into the single bodyDeltas channel and deleted
|
|
// them from StatePreservationInput — applyStatePreservation no longer reads
|
|
// these properties at all. Kept (rather than stripped from every call site
|
|
// below) as an inert, harmless spread: StatePreservationInput is a TypeScript
|
|
// type these plain-JS tests are not checked against, so an extra own
|
|
// property is silently ignored at runtime, and removing it from ~20 call
|
|
// sites would be pure churn with no behavior change. neutralBodyDeltas()
|
|
// already supplies the real (bodyDeltas-based) no-op deltas these fields
|
|
// need post-refactor.
|
|
const dedicatedNoop = {
|
|
preBodyStatus: 'x', postBodyStatus: 'x',
|
|
preBodyStoppedAt: 'x', postBodyStoppedAt: 'x',
|
|
preBodyPhaseSource: 'x', postBodyPhaseSource: 'x',
|
|
};
|
|
|
|
describe('#3468 matrix A: executor policy dispatch (ADR-3408 §8.1) — new/boundary/hostile rows', () => {
|
|
test('A3: preserve-when-unchanged — an empty-string snapshot is not restored', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { current_plan: '' },
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: { current_plan: 'derived' },
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(r.postFm.current_plan, 'derived');
|
|
assert.equal(r.mutated, false);
|
|
});
|
|
|
|
// A4 (matrix: "likely gap today"): a whitespace-only snapshot passes the
|
|
// current `.length > 0` check (3 > 0) and DOES get restored today. The
|
|
// required behavior is skip. This is expected to FAIL against the current
|
|
// tree until the refactor tightens the guard.
|
|
test('A4: preserve-when-unchanged — a whitespace-only snapshot is not restored (".length > 0" is not enough)', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { current_plan: ' ' },
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: { current_plan: 'derived' },
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(r.postFm.current_plan, 'derived',
|
|
'a whitespace-only snapshot must not be treated as a real curated value');
|
|
assert.equal(r.mutated, false);
|
|
});
|
|
|
|
test('A5: preserve-when-unchanged — a non-string snapshot is ignored (no throw)', () => {
|
|
for (const snapshot of [42, true, null, { nested: 1 }, undefined]) {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { current_plan: snapshot },
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: { current_plan: 'derived' },
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(r.postFm.current_plan, 'derived', `snapshot=${JSON.stringify(snapshot)} must not be restored`);
|
|
}
|
|
});
|
|
|
|
test('A6: preserve-when-unchanged — no-op when postFm already equals the snapshot', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { current_plan: 'same-value' },
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: { current_plan: 'same-value' },
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(r.postFm.current_plan, 'same-value');
|
|
assert.equal(r.mutated, false);
|
|
});
|
|
|
|
test('A7: preserve-when-unchanged — a postFm missing the key entirely is restored (undefined !== snapshot)', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { current_plan: 'curated' },
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: {}, // key absent entirely
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(r.postFm.current_plan, 'curated');
|
|
assert.equal(r.mutated, true);
|
|
});
|
|
|
|
test('A8: status — the "unknown" sentinel snapshot is never restored', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { status: 'unknown' },
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: { status: 'verifying' },
|
|
preBodyStatus: 'x',
|
|
postBodyStatus: 'x',
|
|
preBodyStoppedAt: 'x',
|
|
postBodyStoppedAt: 'x',
|
|
preBodyPhaseSource: 'x',
|
|
postBodyPhaseSource: 'x',
|
|
});
|
|
assert.equal(r.postFm.status, 'verifying');
|
|
assert.equal(r.mutated, false);
|
|
});
|
|
|
|
test('A9: status — the "unknown" sentinel guard is case-sensitive ("Unknown" is a real value, restored)', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { status: 'Unknown' },
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: { status: 'verifying' },
|
|
preBodyStatus: 'x',
|
|
postBodyStatus: 'x',
|
|
preBodyStoppedAt: 'x',
|
|
postBodyStoppedAt: 'x',
|
|
preBodyPhaseSource: 'x',
|
|
postBodyPhaseSource: 'x',
|
|
});
|
|
assert.equal(r.postFm.status, 'Unknown',
|
|
'the sentinel is an exact-match on the lowercase literal "unknown" — a case variant is a real value');
|
|
assert.equal(r.mutated, true);
|
|
});
|
|
|
|
test('A12: preserve-always progress — preFm===null under !resync does not throw (skip)', () => {
|
|
assert.doesNotThrow(() => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: {},
|
|
resync: false,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: { progress: { total_phases: 5, completed_phases: 1, percent: 20 } },
|
|
...dedicatedNoop,
|
|
});
|
|
assert.deepEqual(r.postFm.progress, { total_phases: 5, completed_phases: 1, percent: 20 });
|
|
assert.equal(r.mutated, false);
|
|
});
|
|
});
|
|
|
|
test('A14: deriveProgressKeys — derived completed_plans === curated keeps curated (limit: ">" not ">=", #2969)', () => {
|
|
const curated = { progress: { total_plans: 10, completed_plans: 7, percent: 70 } };
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: curated,
|
|
resync: false,
|
|
deriveProgressKeys: true,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: { progress: { total_plans: 10, completed_plans: 7, percent: 70 } }, // derived === curated
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(r.postFm.progress.completed_plans, 7,
|
|
'equal counts (derived === curated) must keep curated — the ratchet is ">" not ">="');
|
|
});
|
|
|
|
test('A18: preserve-if-placeholder — punctuation-led derived names are rejected for every delimiter', () => {
|
|
for (const derived of ['— Foo', ': Foo', '-Foo']) {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { milestone: '1.0', milestone_name: 'Real Curated Name' },
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: { milestone: '1.1', milestone_name: derived },
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(r.postFm.milestone_name, 'Real Curated Name',
|
|
`punctuation-led derived name ${JSON.stringify(derived)} must be rejected`);
|
|
}
|
|
});
|
|
|
|
test('A19: preserve-if-placeholder — an empty-string derived name is rejected (restored)', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { milestone: '1.0', milestone_name: 'Real Curated Name' },
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: { milestone: '1.1', milestone_name: '' },
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(r.postFm.milestone_name, 'Real Curated Name');
|
|
});
|
|
|
|
test('A20: preserve-if-placeholder — a placeholder snapshot is not restored over a placeholder derived value', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { milestone: '1.0', milestone_name: 'milestone' }, // snapshot IS the placeholder
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: { milestone: '1.1', milestone_name: 'milestone' }, // derived is also the placeholder
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(r.postFm.milestone_name, 'milestone', 'nothing better to restore — value passes through unchanged');
|
|
assert.equal(r.postFm.milestone, '1.1', 'milestone version passes through unchanged alongside it');
|
|
});
|
|
|
|
test('A21: preserve-if-placeholder — a real, different derived name wins over the curated snapshot', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { milestone: '1.0', milestone_name: 'Old Curated Name' },
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: { milestone: '2.0', milestone_name: 'New Real Milestone Name' },
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(r.postFm.milestone_name, 'New Real Milestone Name');
|
|
assert.equal(r.postFm.milestone, '2.0');
|
|
});
|
|
|
|
// A22 (design 40-design.md row 17 / matrix "new — row 17"): a `derive` row
|
|
// must be an explicit no-op — untouched, no throw, mutated unaffected.
|
|
// NOTE: as a black-box behavioral probe against the public
|
|
// applyStatePreservation API (the only seam this test file can drive),
|
|
// this currently ALREADY PASSES — today's loop filters every field to
|
|
// `cls.preservation === 'preserve-when-unchanged'` before doing anything,
|
|
// so a `derive` row is skipped by omission rather than by an explicit
|
|
// branch. The matrix's "no branch exists" is a source-structure claim
|
|
// (§8.1's "exactly one executor per policy") that this black-box test
|
|
// cannot distinguish from "skipped by omission" — see report.
|
|
test('A22: derive rows (last_updated, state_head, gsd_state_version, last_activity) are an explicit no-op', () => {
|
|
for (const field of ['last_updated', 'state_head', 'gsd_state_version', 'last_activity']) {
|
|
assert.doesNotThrow(() => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { [field]: 'curated-value' },
|
|
resync: true,
|
|
bodyDeltas: { ...neutralBodyDeltas(), [field]: { pre: 'old', post: 'new' } },
|
|
}),
|
|
postFm: { [field]: 'freshly-derived-value' },
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(r.postFm[field], 'freshly-derived-value',
|
|
`${field}: a derive row always takes the freshly-derived (postFm) value`);
|
|
});
|
|
}
|
|
});
|
|
|
|
test('A23: a field with no FIELD_CLASSIFICATION row passes through untouched', () => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { totally_unclassified_field: 'curated' },
|
|
resync: true,
|
|
bodyDeltas: { ...neutralBodyDeltas(), totally_unclassified_field: { pre: 'x', post: 'y' } },
|
|
}),
|
|
postFm: { totally_unclassified_field: 'derived' },
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(r.postFm.totally_unclassified_field, 'derived');
|
|
assert.equal(r.mutated, false);
|
|
});
|
|
|
|
test('A24: prototype-pollution — __proto__ / constructor / toString never resolve to inherited classifications', () => {
|
|
for (const hostileKey of ['__proto__', 'constructor', 'toString']) {
|
|
assert.strictEqual(getFieldClassification(hostileKey), null,
|
|
`${hostileKey} must not resolve to an inherited Object member`);
|
|
}
|
|
// applyStatePreservation itself must not crash or misbehave when a
|
|
// hostile field name rides along in postFm/preFmSnapshot/bodyDeltas.
|
|
// Computed keys (not literal `__proto__:`) create genuine OWN properties
|
|
// instead of mutating the object's prototype.
|
|
assert.doesNotThrow(() => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { ['__proto__']: 'x', ['constructor']: 'y', ['toString']: 'z' },
|
|
resync: true,
|
|
bodyDeltas: {
|
|
...neutralBodyDeltas(),
|
|
['__proto__']: { pre: 'x', post: 'y' },
|
|
['constructor']: { pre: 'x', post: 'y' },
|
|
['toString']: { pre: 'x', post: 'y' },
|
|
},
|
|
}),
|
|
postFm: { ['__proto__']: 'a', ['constructor']: 'b', ['toString']: 'c' },
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(typeof r.postFm, 'object');
|
|
assert.equal(typeof r.mutated, 'boolean');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// #3468 matrix B: the §8.2 unenforced-row invariant.
|
|
//
|
|
// Structured error shape THIS TASK REQUIRES THE IMPLEMENTATION TO PROVIDE
|
|
// (CONTRIBUTING.md § Prohibited: Raw Text Matching — the thrown error must
|
|
// carry typed properties, never asserted via message-prose matching):
|
|
// err.code === 'STATE_PRESERVATION_UNWIRED_ROW'
|
|
// err.field === '<the FIELD_CLASSIFICATION key that was not wired>'
|
|
//
|
|
// B1-B3 are EXPECTED TO FAIL against the current tree: today's loop at
|
|
// src/state-transition.cts:314 does `if (!delta) continue;` — a silent skip,
|
|
// not a throw. This is the exact defect §8.2 requires fixing.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#3468 matrix B: an unenforced preserve-when-unchanged row (ADR-3408 §8.2)', () => {
|
|
test('B1: a declared preserve-when-unchanged row missing from bodyDeltas throws, naming the field', () => {
|
|
const bodyDeltas = neutralBodyDeltas();
|
|
delete bodyDeltas.current_plan; // the ONLY unwired row
|
|
assert.throws(
|
|
() => applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { current_plan: 'curated' },
|
|
resync: true,
|
|
bodyDeltas: bodyDeltas,
|
|
}),
|
|
postFm: { current_plan: 'derived' },
|
|
...dedicatedNoop,
|
|
}),
|
|
(err) => {
|
|
assert.strictEqual(err.code, 'STATE_PRESERVATION_UNWIRED_ROW');
|
|
assert.strictEqual(err.field, 'current_plan');
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test('B2: bodyDeltas entirely absent throws, naming the first unwired row', () => {
|
|
assert.throws(
|
|
() => applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: {},
|
|
resync: true,
|
|
// bodyDeltas omitted entirely
|
|
}),
|
|
postFm: {},
|
|
...dedicatedNoop,
|
|
}),
|
|
(err) => {
|
|
assert.strictEqual(err.code, 'STATE_PRESERVATION_UNWIRED_ROW');
|
|
// current_phase is the first preserve-when-unchanged field in
|
|
// FIELD_CLASSIFICATION's insertion order — before current_phase_name,
|
|
// current_plan, status, stopped_at, paused_at, last_activity_desc,
|
|
// all of which share the same loop post-#3468 (no field stays on a
|
|
// dedicated channel any more).
|
|
assert.strictEqual(err.field, 'current_phase');
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test('B3: bodyDeltas present but {} throws, naming the first unwired row', () => {
|
|
assert.throws(
|
|
() => applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: {},
|
|
resync: true,
|
|
bodyDeltas: {},
|
|
}),
|
|
postFm: {},
|
|
...dedicatedNoop,
|
|
}),
|
|
(err) => {
|
|
assert.strictEqual(err.code, 'STATE_PRESERVATION_UNWIRED_ROW');
|
|
assert.strictEqual(err.field, 'current_phase');
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test('B4: a delta shaped {pre:null,post:null} is treated as WIRED, not missing (the boundary that separates B1 from A1)', () => {
|
|
const bodyDeltas = {
|
|
...neutralBodyDeltas(),
|
|
current_phase: { pre: null, post: null },
|
|
};
|
|
assert.doesNotThrow(() => {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: {}, // no snapshot — skip is a legitimate, non-throwing outcome
|
|
resync: true,
|
|
bodyDeltas: bodyDeltas,
|
|
}),
|
|
postFm: { current_phase: 'derived' },
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(r.postFm.current_phase, 'derived');
|
|
});
|
|
});
|
|
|
|
test('B5: a partially-shaped delta ({post} with no pre key) does not throw — treated as wired', () => {
|
|
const bodyDeltas = {
|
|
...neutralBodyDeltas(),
|
|
current_phase: { post: 'x' }, // `pre` key entirely absent
|
|
};
|
|
assert.doesNotThrow(() => {
|
|
applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { current_phase: 'curated' },
|
|
resync: true,
|
|
bodyDeltas: bodyDeltas,
|
|
}),
|
|
postFm: { current_phase: 'derived' },
|
|
...dedicatedNoop,
|
|
});
|
|
});
|
|
});
|
|
|
|
test('B6: only preserve-when-unchanged rows require wiring — derive/preserve-always/preserve-if-placeholder do not', () => {
|
|
// Every preserve-when-unchanged row IS wired here; last_updated (derive),
|
|
// progress (preserve-always), and milestone_name (preserve-if-placeholder)
|
|
// are deliberately absent from bodyDeltas and must not trigger the throw.
|
|
assert.doesNotThrow(() => {
|
|
applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: {},
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: {},
|
|
...dedicatedNoop,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// #3468 matrix C: behavior identity across the refactor (ADR-3408 §8.1).
|
|
//
|
|
// Literal, pinned expected values — not derived from a shared helper — per
|
|
// 40-design.md's "Known limits": the reclassification of current_phase_name
|
|
// is behavior-preserving by design, so the existing suite staying green
|
|
// proves nothing; only a pinned before/after comparison catches a silent
|
|
// drift (CONTRIBUTING.md § Fixture provenance #2371).
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#3468 matrix C1/C2: identity across the refactor — pinned literal outputs', () => {
|
|
const GOOD = 'preserved-by-table';
|
|
const BAD = 'clobbered-by-derive';
|
|
|
|
// Four of the seven preserve-when-unchanged rows, pinned generically here;
|
|
// status/stopped_at get their own pinned test above (their 'unknown'
|
|
// sentinel guard has no analogue in these four), and current_phase_name
|
|
// gets its own dedicated C2 test below (it needs BOTH an unchanged AND a
|
|
// changed delta for the same field, which this generic loop cannot express).
|
|
const LOOP_PWU_FIELDS = ['current_phase', 'current_plan', 'paused_at', 'last_activity_desc'];
|
|
|
|
test('C1: shared preserve-when-unchanged loop — delta unchanged restores the snapshot (pinned)', () => {
|
|
for (const field of LOOP_PWU_FIELDS) {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { [field]: GOOD },
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: { [field]: BAD },
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(r.postFm[field], GOOD, `${field}: pinned identity — restore-when-unchanged`);
|
|
}
|
|
});
|
|
|
|
test('C1: shared preserve-when-unchanged loop — delta changed lets derived win (pinned)', () => {
|
|
for (const field of LOOP_PWU_FIELDS) {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { [field]: GOOD },
|
|
resync: true,
|
|
bodyDeltas: { ...neutralBodyDeltas(), [field]: { pre: 'old', post: 'new' } },
|
|
}),
|
|
postFm: { [field]: BAD },
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(r.postFm[field], BAD, `${field}: pinned identity — derived wins when body source changed`);
|
|
}
|
|
});
|
|
|
|
test('C1: status / stopped_at (pinned)', () => {
|
|
const rStatus = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { status: GOOD },
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: { status: BAD },
|
|
});
|
|
assert.equal(rStatus.postFm.status, GOOD);
|
|
|
|
const rStopped = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { stopped_at: GOOD },
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: { stopped_at: BAD },
|
|
});
|
|
assert.equal(rStopped.postFm.stopped_at, GOOD);
|
|
});
|
|
|
|
test('C1: preserve-always progress and preserve-if-placeholder milestone/milestone_name (pinned)', () => {
|
|
const curated = { progress: { total_phases: 4, completed_phases: 3, percent: 75 } };
|
|
const rProgress = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: curated,
|
|
resync: false,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: { progress: { total_phases: 5, completed_phases: 0, percent: 0 } },
|
|
...dedicatedNoop,
|
|
});
|
|
assert.deepEqual(rProgress.postFm.progress, curated.progress);
|
|
|
|
const rMilestone = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { milestone: '1.0', milestone_name: GOOD },
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltas(),
|
|
}),
|
|
postFm: { milestone: '1.1', milestone_name: 'milestone' },
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(rMilestone.postFm.milestone_name, GOOD);
|
|
});
|
|
|
|
test('C1: derive-classified fields pass through untouched regardless of snapshot (pinned)', () => {
|
|
for (const field of ['gsd_state_version', 'last_updated', 'last_activity', 'state_head']) {
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { [field]: GOOD },
|
|
resync: true,
|
|
bodyDeltas: { ...neutralBodyDeltas(), [field]: { pre: 'old', post: 'new' } },
|
|
}),
|
|
postFm: { [field]: BAD },
|
|
...dedicatedNoop,
|
|
});
|
|
assert.equal(r.postFm[field], BAD, `${field}: derive rows always take the derived (postFm) value`);
|
|
}
|
|
});
|
|
|
|
// C2: current_phase_name's row is reclassified preserve-always →
|
|
// preserve-when-unchanged (#3468, ADR-3408 §8.1 amendment) as a
|
|
// BEHAVIOR-PRESERVING change (40-design.md). Both outcomes are pinned
|
|
// literally so a post-refactor drift is caught even though the existing
|
|
// suite staying green would prove nothing. The delta now travels through
|
|
// bodyDeltas.current_phase_name (folded from the pre-#3468 dedicated
|
|
// preBodyPhaseSource/postBodyPhaseSource parameter pair, which #3468
|
|
// deleted from StatePreservationInput) rather than through
|
|
// neutralBodyDeltas()'s generic "unchanged" default, since this test needs
|
|
// to drive both an unchanged AND a changed delta for the SAME field.
|
|
test('C2: current_phase_name (reclassified preserve-always → preserve-when-unchanged) — pinned outputs', () => {
|
|
const rEqual = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { current_phase_name: GOOD },
|
|
resync: true,
|
|
bodyDeltas: { ...neutralBodyDeltas(), current_phase_name: { pre: '3', post: '3' } },
|
|
}),
|
|
postFm: { current_phase_name: BAD },
|
|
});
|
|
assert.equal(rEqual.postFm.current_phase_name, GOOD,
|
|
'reclassification must not change this: unchanged body Phase source still restores the curated name');
|
|
assert.equal(rEqual.mutated, true);
|
|
|
|
const rDiffer = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: { current_phase_name: GOOD },
|
|
resync: true,
|
|
bodyDeltas: { ...neutralBodyDeltas(), current_phase_name: { pre: '3', post: '4' } },
|
|
}),
|
|
postFm: { current_phase_name: BAD },
|
|
});
|
|
assert.equal(rDiffer.postFm.current_phase_name, BAD,
|
|
'reclassification must not change this: changed body Phase source still lets derived win');
|
|
assert.equal(rDiffer.mutated, false);
|
|
});
|
|
});
|
|
|
|
describe('#3468 matrix C3: executor dispatch is a pure function of the row policy (property)', () => {
|
|
// Every preserve-when-unchanged field EXCEPT status/stopped_at — excluded
|
|
// because status carries the 'unknown' sentinel guard (a field-specific
|
|
// exception this generic property does not model) and stopped_at is its
|
|
// paired dedicated-channel sibling in the pre-#3468 design this comment
|
|
// originally described; both get their own pinned coverage in C1 above.
|
|
const LOOP_PWU_FIELDS = Object.keys(FIELD_CLASSIFICATION).filter((f) => {
|
|
const cls = getFieldClassification(f);
|
|
return cls !== null && cls.preservation === 'preserve-when-unchanged' && f !== 'status' && f !== 'stopped_at';
|
|
});
|
|
|
|
test('property: restore fires iff (wired AND non-empty snapshot AND unchanged delta AND postFm differs)', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.constantFrom(...LOOP_PWU_FIELDS),
|
|
fc.string({ maxLength: 20 }),
|
|
fc.boolean(),
|
|
fc.string({ maxLength: 20 }),
|
|
fc.boolean(),
|
|
fc.boolean(),
|
|
(field, snapshotValue, snapshotPresent, postFmValue, deltaChanged, resync) => {
|
|
const preFmSnapshot = snapshotPresent ? { [field]: snapshotValue } : {};
|
|
const postFm = { [field]: postFmValue };
|
|
const delta = { pre: 'source', post: deltaChanged ? 'source-changed' : 'source' };
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: preFmSnapshot,
|
|
resync: resync,
|
|
bodyDeltas: { ...neutralBodyDeltas(), [field]: delta },
|
|
}),
|
|
postFm: postFm,
|
|
...dedicatedNoop,
|
|
});
|
|
|
|
const snapshotUsable = snapshotPresent && snapshotValue.length > 0;
|
|
const alreadyCorrect = postFmValue === snapshotValue;
|
|
const expectRestore = snapshotUsable && !deltaChanged && !alreadyCorrect;
|
|
|
|
const expectedValue = expectRestore ? snapshotValue : postFmValue;
|
|
if (r.postFm[field] !== expectedValue || r.mutated !== expectRestore) {
|
|
throw new Error(
|
|
`dispatch mismatch: field=${field} snapshotPresent=${snapshotPresent} ` +
|
|
`snapshotValue=${JSON.stringify(snapshotValue)} postFmValue=${JSON.stringify(postFmValue)} ` +
|
|
`deltaChanged=${deltaChanged} resync=${resync} expectRestore=${expectRestore} ` +
|
|
`got postFm[field]=${JSON.stringify(r.postFm[field])} mutated=${r.mutated}`,
|
|
);
|
|
}
|
|
return true;
|
|
},
|
|
),
|
|
{ seed: 3468, numRuns: 200 },
|
|
);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-21-state-md-template-frontmatter.test.cjs — consolidation epic #1969 (B8 #1977)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-21-state-md-template-frontmatter (consolidation epic #1969 B8 #1977)", () => {
|
|
/**
|
|
* Regression guard — Bug #21
|
|
*
|
|
* Both STATE.md template files must include a YAML frontmatter block in their
|
|
* "File Template" section so that an AI agent creating .planning/STATE.md from
|
|
* the template produces a file that frontmatter consumers can read immediately
|
|
* (before the first `state.*` mutation calls syncStateFrontmatter).
|
|
*
|
|
* Prior to the fix, the template's File Template section began with
|
|
* `# Project State` (no frontmatter), leaving the init→first-write window
|
|
* without `gsd_state_version`, `status`, or `progress` keys.
|
|
*
|
|
* Acceptance criteria:
|
|
* 1. The template body extracted from each state.md file's File Template code
|
|
* block must begin with `---`.
|
|
* 2. The frontmatter must contain at minimum: `gsd_state_version` and `status`.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
|
|
const TEMPLATE_PATHS = [
|
|
path.join(REPO_ROOT, 'gsd-core', 'templates', 'state.md'),
|
|
];
|
|
|
|
/**
|
|
* Extract the content of the first ```markdown ... ``` code block from a
|
|
* template file. Returns the raw string (including any leading/trailing
|
|
* whitespace within the block).
|
|
*
|
|
* @param {string} fileContent - Full text of the template file.
|
|
* @returns {string} The extracted code block body.
|
|
*/
|
|
function extractFileTemplate(fileContent) {
|
|
// Deliberately independent of the generator's own fence-handling — this is
|
|
// the bug #21 regression guard that must not share the seam
|
|
// gen-state-md-docs.cjs uses (see tests/gen-state-md-docs.test.cjs).
|
|
const match = fileContent.match(/```markdown\r?\n([\s\S]*?)```/); // allow-adhoc-markdown: deliberately independent of the generator's fence-handling — regresses bug #21
|
|
assert.ok(match, 'No ```markdown code block found in template file');
|
|
return match[1];
|
|
}
|
|
|
|
/**
|
|
* Minimal YAML frontmatter parser: returns the set of top-level keys present
|
|
* in the first --- ... --- block at the start of `text`. Does not parse nested
|
|
* keys — list-valued fields (e.g. `tags: [a, b]`) are recorded only by their
|
|
* key name, not their value. Returns an empty Set when the text has no frontmatter.
|
|
*
|
|
* @param {string} text
|
|
* @returns {Set<string>}
|
|
*/
|
|
function parseFrontmatterKeys(text) {
|
|
const keys = new Set();
|
|
if (!text.trimStart().startsWith('---')) return keys;
|
|
const lines = text.split(/\r?\n/);
|
|
let inBlock = false;
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!inBlock) {
|
|
if (trimmed === '---') { inBlock = true; continue; }
|
|
break; // frontmatter must be at the very start
|
|
}
|
|
if (trimmed === '---') break; // end of block
|
|
const colonIdx = trimmed.indexOf(':');
|
|
if (colonIdx > 0) {
|
|
keys.add(trimmed.slice(0, colonIdx).trim());
|
|
}
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
/**
|
|
* Minimal YAML frontmatter parser: returns a plain object of top-level keys
|
|
* and their scalar or nested-object values from the first --- ... --- block.
|
|
* Handles one level of indented nesting (e.g. progress.total_plans).
|
|
* Does not handle YAML lists or multi-line values.
|
|
*
|
|
* @param {string} text
|
|
* @returns {Record<string, any>}
|
|
*/
|
|
function parseFrontmatter(text) {
|
|
const result = {};
|
|
if (!text.trimStart().startsWith('---')) return result;
|
|
const lines = text.split(/\r?\n/);
|
|
let inBlock = false;
|
|
let currentKey = null;
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!inBlock) {
|
|
if (trimmed === '---') { inBlock = true; continue; }
|
|
break;
|
|
}
|
|
if (trimmed === '---') break;
|
|
// Detect indented (nested) line: starts with whitespace
|
|
if (line.match(/^\s+\S/) && currentKey !== null) {
|
|
const colonIdx = trimmed.indexOf(':');
|
|
if (colonIdx > 0) {
|
|
const subKey = trimmed.slice(0, colonIdx).trim();
|
|
const rawVal = trimmed.slice(colonIdx + 1).trim();
|
|
const numVal = Number(rawVal);
|
|
if (typeof result[currentKey] !== 'object') result[currentKey] = {};
|
|
result[currentKey][subKey] = rawVal === '' ? null : (isNaN(numVal) ? rawVal : numVal);
|
|
}
|
|
} else {
|
|
currentKey = null;
|
|
const colonIdx = trimmed.indexOf(':');
|
|
if (colonIdx > 0) {
|
|
const key = trimmed.slice(0, colonIdx).trim();
|
|
const rawVal = trimmed.slice(colonIdx + 1).trim();
|
|
if (rawVal === '') {
|
|
result[key] = {};
|
|
currentKey = key;
|
|
} else {
|
|
const numVal = Number(rawVal);
|
|
result[key] = isNaN(numVal) ? rawVal.replace(/^'|'$/g, '') : numVal;
|
|
currentKey = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
describe('bug #21 — STATE.md template must carry YAML frontmatter', () => {
|
|
for (const templatePath of TEMPLATE_PATHS) {
|
|
const label = path.relative(REPO_ROOT, templatePath);
|
|
|
|
test(`${label} — File Template block starts with frontmatter`, () => {
|
|
const content = fs.readFileSync(templatePath, 'utf-8');
|
|
const body = extractFileTemplate(content);
|
|
|
|
// The template body must open with a YAML frontmatter delimiter.
|
|
assert.ok(
|
|
body.trimStart().startsWith('---'),
|
|
`${label}: File Template must start with '---' (YAML frontmatter), ` +
|
|
`but starts with: ${JSON.stringify(body.slice(0, 60))}`,
|
|
);
|
|
});
|
|
|
|
test(`${label} — frontmatter contains gsd_state_version`, () => {
|
|
const content = fs.readFileSync(templatePath, 'utf-8');
|
|
const body = extractFileTemplate(content);
|
|
const keys = parseFrontmatterKeys(body.trimStart());
|
|
|
|
assert.ok(
|
|
keys.has('gsd_state_version'),
|
|
`${label}: frontmatter must include 'gsd_state_version', found keys: ${[...keys].join(', ')}`,
|
|
);
|
|
});
|
|
|
|
test(`${label} — frontmatter contains status`, () => {
|
|
const content = fs.readFileSync(templatePath, 'utf-8');
|
|
const body = extractFileTemplate(content);
|
|
const keys = parseFrontmatterKeys(body.trimStart());
|
|
|
|
assert.ok(
|
|
keys.has('status'),
|
|
`${label}: frontmatter must include 'status', found keys: ${[...keys].join(', ')}`,
|
|
);
|
|
});
|
|
|
|
test(`${label} — progress sub-schema has zeroed total_plans and completed_plans`, () => {
|
|
const content = fs.readFileSync(templatePath, 'utf-8');
|
|
const body = extractFileTemplate(content);
|
|
const fm = parseFrontmatter(body.trimStart());
|
|
|
|
assert.ok(
|
|
fm.progress && typeof fm.progress === 'object',
|
|
`${label}: frontmatter must include a 'progress' sub-object`,
|
|
);
|
|
assert.strictEqual(
|
|
fm.progress.total_plans,
|
|
0,
|
|
`${label}: progress.total_plans must be 0 in the template`,
|
|
);
|
|
assert.strictEqual(
|
|
fm.progress.completed_plans,
|
|
0,
|
|
`${label}: progress.completed_plans must be 0 in the template`,
|
|
);
|
|
});
|
|
}
|
|
|
|
});
|
|
});
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// #3118: sliceCurrentPositionSection — locator characterization tests.
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('sliceCurrentPositionSection (#3118)', () => {
|
|
test('slices the section up to the following heading', () => {
|
|
const body = [
|
|
'# Project State',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 3 (Test Phase) — EXECUTING',
|
|
'',
|
|
'## Accumulated Context',
|
|
'',
|
|
'- A decision worth keeping.',
|
|
'',
|
|
].join('\n');
|
|
const result = sliceCurrentPositionSection(body);
|
|
assert.ok(result.includes('Phase: 3 (Test Phase) — EXECUTING'));
|
|
assert.ok(!result.includes('A decision worth keeping'));
|
|
});
|
|
|
|
test('slices to end of document for a trailing section', () => {
|
|
const body = [
|
|
'# Project State',
|
|
'',
|
|
'## Accumulated Context',
|
|
'',
|
|
'- Earlier decision.',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 5 (Final Phase) — EXECUTING',
|
|
'',
|
|
].join('\n');
|
|
const result = sliceCurrentPositionSection(body);
|
|
assert.ok(result.includes('Phase: 5 (Final Phase) — EXECUTING'));
|
|
});
|
|
|
|
test('returns null when the section is absent', () => {
|
|
const body = [
|
|
'# Project State',
|
|
'',
|
|
'## Accumulated Context',
|
|
'',
|
|
'- Some decision.',
|
|
'',
|
|
'## Deferred Items',
|
|
'',
|
|
'- Something deferred.',
|
|
'',
|
|
].join('\n');
|
|
assert.strictEqual(sliceCurrentPositionSection(body), null);
|
|
});
|
|
|
|
test('matches the heading case- and space-insensitively', () => {
|
|
const body = [
|
|
'# Project State',
|
|
'',
|
|
'## CURRENT POSITION',
|
|
'',
|
|
'Phase: 2 — EXECUTING',
|
|
'',
|
|
].join('\n');
|
|
assert.notStrictEqual(sliceCurrentPositionSection(body), null);
|
|
});
|
|
|
|
test('ignores a Current Position heading inside a code fence', () => {
|
|
// The locator is fence-aware via `tokenizeHeadings` — a `##` line inside
|
|
// a ``` fence is not a real heading, so this document has zero *real*
|
|
// Current Position headings.
|
|
const body = [
|
|
'# Project State',
|
|
'',
|
|
'## Accumulated Context',
|
|
'',
|
|
'```markdown',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 9 — should not be seen',
|
|
'```',
|
|
'',
|
|
].join('\n');
|
|
assert.strictEqual(sliceCurrentPositionSection(body), null);
|
|
});
|
|
|
|
test('distinguishes an empty section from an absent one', () => {
|
|
// An empty section and an absent one are different answers, and a caller
|
|
// that folds them together reintroduces the collapse this epic removes.
|
|
const body = [
|
|
'# Project State',
|
|
'',
|
|
'## Current Position',
|
|
'## Accumulated Context',
|
|
'',
|
|
'- A decision.',
|
|
'',
|
|
].join('\n');
|
|
const result = sliceCurrentPositionSection(body);
|
|
assert.strictEqual(typeof result, 'string');
|
|
assert.notStrictEqual(result, null);
|
|
assert.strictEqual(result.trim(), '');
|
|
});
|
|
|
|
test('does not match an H3 Current Position', () => {
|
|
const body = [
|
|
'# Project State',
|
|
'',
|
|
'### Current Position',
|
|
'',
|
|
'Phase: 2 — EXECUTING',
|
|
'',
|
|
].join('\n');
|
|
assert.strictEqual(sliceCurrentPositionSection(body), null);
|
|
});
|
|
|
|
test('slices the first Current Position when the document has two', () => {
|
|
// `findIndex` picks the first heading match and nothing pinned that
|
|
// behavior down before this test.
|
|
const body = [
|
|
'# Project State',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 3 — FIRST OCCURRENCE',
|
|
'',
|
|
'## Accumulated Context',
|
|
'',
|
|
'- unrelated',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 9 — SECOND OCCURRENCE',
|
|
'',
|
|
].join('\n');
|
|
const result = sliceCurrentPositionSection(body);
|
|
assert.ok(result.includes('FIRST OCCURRENCE'));
|
|
assert.ok(!result.includes('SECOND OCCURRENCE'));
|
|
});
|
|
|
|
test('returns null for an empty document', () => {
|
|
assert.strictEqual(sliceCurrentPositionSection(''), null);
|
|
});
|
|
|
|
test('slices a CRLF document identically', () => {
|
|
// Only `\n` in a regex/split is the recurring CRLF defect class in this
|
|
// repo (#1658 and successors) — verify the CRLF fixture, normalized back
|
|
// to LF, matches the LF fixture's result byte-for-byte.
|
|
const lines = [
|
|
'# Project State',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 3 (Test Phase) — EXECUTING',
|
|
'',
|
|
'## Accumulated Context',
|
|
'',
|
|
'- A decision worth keeping.',
|
|
'',
|
|
];
|
|
const lfResult = sliceCurrentPositionSection(lines.join('\n'));
|
|
const crlfResult = sliceCurrentPositionSection(lines.join('\r\n'));
|
|
// Strict form (#3118): normalize CRLF->LF and require exact equality with
|
|
// the LF result. The looser `.replace(/\r/g, '')` form (previously used
|
|
// here) strips ALL `\r` bytes including a stray unpaired trailing `\r`
|
|
// left by the pre-fix `end = hs[j].offset - 1` slice — that loose
|
|
// assertion is what let the CRLF-slice-defect ship undetected.
|
|
assert.strictEqual(crlfResult.replace(/\r\n/g, '\n'), lfResult);
|
|
});
|
|
|
|
test('returns an empty string when the section is empty and the next heading follows immediately', () => {
|
|
const body = ['# STATE', '', '## Current Position', '## Next Section', 'content'].join('\n');
|
|
const result = sliceCurrentPositionSection(body);
|
|
assert.strictEqual(result, '');
|
|
});
|
|
|
|
test('does not duplicate bytes when a transition mutates an empty adjacent section', () => {
|
|
// Regression for #3118 review MAJOR: `locateCurrentPosition`'s newline
|
|
// walk-back could land `end` before `start` when the section is empty
|
|
// and the next heading follows with no blank line between. Every
|
|
// mutator's `body.slice(0, start) + sectionBody + body.slice(end)`
|
|
// reassembly then duplicated the bytes in the inverted `[end, start)`
|
|
// range — a spurious blank line (LF) or `\r\n` (CRLF) inserted into
|
|
// STATE.md on every transition.
|
|
const lfBody = ['# STATE', '', '## Current Position', '## Next Section', 'content'].join('\n');
|
|
const lfResult = transitionCore(
|
|
lfBody,
|
|
{ kind: 'beginPhase', phaseNumber: 3, phaseName: null, planCount: null },
|
|
{ clock: fixedClock },
|
|
);
|
|
assert.ok(
|
|
!lfResult.content.includes('## Current Position\n\n## Next Section'),
|
|
`expected no inserted blank line; got ${JSON.stringify(lfResult.content)}`,
|
|
);
|
|
assert.strictEqual(
|
|
lfResult.content,
|
|
'# STATE\n\n## Current Position\n## Next Section\ncontent',
|
|
);
|
|
assert.strictEqual(lfResult.content.length, lfBody.length);
|
|
|
|
const crlfBody = ['# STATE', '', '## Current Position', '## Next Section', 'content'].join('\r\n');
|
|
const crlfResult = transitionCore(
|
|
crlfBody,
|
|
{ kind: 'beginPhase', phaseNumber: 3, phaseName: null, planCount: null },
|
|
{ clock: fixedClock },
|
|
);
|
|
assert.ok(
|
|
!crlfResult.content.includes('## Current Position\r\n\r\n## Next Section'),
|
|
`expected no inserted CRLF; got ${JSON.stringify(crlfResult.content)}`,
|
|
);
|
|
assert.strictEqual(
|
|
crlfResult.content,
|
|
'# STATE\r\n\r\n## Current Position\r\n## Next Section\r\ncontent',
|
|
);
|
|
assert.strictEqual(crlfResult.content.length, crlfBody.length);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// #3118: deps.progressProvider is a required StateTransitionDeps field with
|
|
// 33 supply sites and zero call sites, and is being removed. Prove it
|
|
// behaviorally: no transition ever invokes it.
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('state transitions do not consult a progress provider (#3118)', () => {
|
|
test('no transition invokes deps.progressProvider', () => {
|
|
// An exploding stub is the behavioral form of "this field is inert";
|
|
// asserting the declaration is absent would be source-grep theater.
|
|
const exploding = () => { throw new Error('progressProvider must never be called'); };
|
|
const clock = fixedClock;
|
|
|
|
assert.doesNotThrow(() => transitionCore(
|
|
firstTimeBody(),
|
|
{ kind: 'beginPhase', phaseNumber: 3, phaseName: 'Test Phase', planCount: 5 },
|
|
{ clock, progressProvider: exploding },
|
|
));
|
|
|
|
assert.doesNotThrow(() => transitionCore(
|
|
[
|
|
'# Project State',
|
|
'',
|
|
'**Current Plan:** 02',
|
|
'**Total Plans in Phase:** 05',
|
|
'**Status:** Executing Phase 3',
|
|
'**Last Activity:** 2026-06-26',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Plan: 2 of 5',
|
|
'Status: Executing Phase 3',
|
|
'',
|
|
].join('\n'),
|
|
{ kind: 'advancePlan' },
|
|
{ clock, progressProvider: exploding },
|
|
));
|
|
|
|
assert.doesNotThrow(() => transitionCore(
|
|
completePhaseBody(),
|
|
{ kind: 'completePhase', phaseNum: '3', nextPhaseNum: '4', nextPhaseName: 'Design Phase', isLastPhase: false, planCount: 3, summaryCount: 3 },
|
|
{ clock, progressProvider: exploding, roadmapProvider: () => ROADMAP_3_OF_5 },
|
|
));
|
|
|
|
assert.doesNotThrow(() => transitionCore(
|
|
plannedPhaseBody(),
|
|
{ kind: 'plannedPhase', phaseNumber: 3, planCount: 4 },
|
|
{ clock, progressProvider: exploding },
|
|
));
|
|
|
|
const milestoneBody = [
|
|
'---',
|
|
'gsd_state_version: 1.0',
|
|
'milestone: v1.0',
|
|
'milestone_name: Old Milestone',
|
|
'status: executing',
|
|
'current_phase: "3"',
|
|
'progress:',
|
|
' total_phases: 5',
|
|
' completed_phases: 2',
|
|
' percent: 40',
|
|
'---',
|
|
'',
|
|
'# Project State',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 3 — EXECUTING',
|
|
'Plan: 2 of 5',
|
|
'Status: Executing Phase 3',
|
|
'Last activity: 2026-06-20 — mid-flight',
|
|
'',
|
|
].join('\n');
|
|
assert.doesNotThrow(() => transitionCore(
|
|
milestoneBody,
|
|
{ kind: 'milestoneSwitch', version: 'v2.0', name: 'New Milestone' },
|
|
{ clock, progressProvider: exploding },
|
|
));
|
|
|
|
const preCloseBody = [
|
|
'# Project State',
|
|
'',
|
|
'**Status:** Executing Phase 5',
|
|
'**Last Activity:** 2026-06-20',
|
|
'**Last Activity Description:** mid-flight',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 5 — EXECUTING',
|
|
'Plan: 2 of 3',
|
|
'Status: Executing Phase 5',
|
|
'Last activity: 2026-06-20 — running',
|
|
'',
|
|
'## Operator Next Steps',
|
|
'',
|
|
'- Re-run /gsd:complete-milestone v1.0',
|
|
'',
|
|
].join('\n');
|
|
assert.doesNotThrow(() => transitionCore(
|
|
preCloseBody,
|
|
{ kind: 'milestoneComplete', version: 'v1.0', nextMilestoneCommand: '/gsd:new-milestone' },
|
|
{ clock, progressProvider: exploding },
|
|
));
|
|
|
|
assert.doesNotThrow(() => transitionCore(
|
|
[
|
|
'# Project State',
|
|
'',
|
|
'**Status:** Planning',
|
|
'**Current Plan:** 2',
|
|
'**Total Plans in Phase:** 5',
|
|
'',
|
|
].join('\n'),
|
|
{ kind: 'patch', patches: { Status: 'Paused', 'Current Plan': '3' } },
|
|
{ clock, progressProvider: exploding },
|
|
));
|
|
|
|
assert.doesNotThrow(() => transitionCore(
|
|
'# Project State\n\n**Status:** Planning\n**Current Plan:** 2\n',
|
|
{ kind: 'update', field: 'Current Plan', value: '3' },
|
|
{ clock, progressProvider: exploding },
|
|
));
|
|
|
|
assert.doesNotThrow(() => transitionCore(
|
|
[
|
|
'# Session State',
|
|
'',
|
|
'## Decisions',
|
|
'',
|
|
'- [Phase 1]: Old',
|
|
'- [Phase 3]: Older',
|
|
'- [Phase 9]: Recent',
|
|
'',
|
|
].join('\n'),
|
|
{ kind: 'prune', cutoff: 7 },
|
|
{ clock, progressProvider: exploding },
|
|
));
|
|
|
|
assert.doesNotThrow(() => transitionCore(
|
|
[
|
|
'# Project State',
|
|
'',
|
|
'**Total Plans in Phase:** 2',
|
|
'**Last Activity:** 2026-06-20',
|
|
'**Progress:** [████░░░░░░] 40%',
|
|
'',
|
|
].join('\n'),
|
|
{ kind: 'sync', totalPlansInPhase: 5, percent: 60 },
|
|
{ clock, progressProvider: exploding },
|
|
));
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// #3871 / #3756 (ADR-3473 §8.6): the `preserve-always` executor for `progress`
|
|
// (state-transition.cjs ~line 280) gates on `ctx.resync || !ctx.preFm ||
|
|
// !ctx.preFm[field]` — it reads `ctx.preFm`, never `ctx.preFmSnapshot`. But
|
|
// `applyPostSyncPreservation` (src/state.cts) computes
|
|
// `const preFm = resync ? null : extractFrontmatter(...)`, and
|
|
// `readModifyWriteStateMd` defaults `resync` to `true` — so on every
|
|
// resyncing write (record-session, add-decision, etc.) `ctx.preFm` is
|
|
// ALWAYS null and the preserve-always branch for `progress` returns
|
|
// immediately without ever consulting the curated snapshot, even though the
|
|
// snapshot (`ctx.preFmSnapshot`) still carries the pre-write curated block.
|
|
// This is the root cause of #3756 (progress zeroed on an archived milestone).
|
|
//
|
|
// Written against TODAY's `applyStatePreservation` signature
|
|
// (`{ preFm, postFm, preFmSnapshot, resync, bodyDeltas }`) so it fails for
|
|
// the RIGHT reason (the policy not running) rather than an import/signature
|
|
// mismatch. The signature is expected to change in the follow-up fix commit
|
|
// (the executor should consult `preFmSnapshot` when `preFm` is null due to
|
|
// resync), at which point this test migrates alongside it.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#3871 / #3756: preserve-always must still run on a resyncing write', () => {
|
|
test('preserveAlwaysRunsOnResyncingWrites', () => {
|
|
// #3756's exact repro shape: STATE.md carries a curated progress block
|
|
// (5/5/32/32/100%) but the post-sync disk-derived block from a
|
|
// milestone-scoped scan that found NONE of the current milestone's
|
|
// phases (they were archived to .planning/milestones/<v>-phases/) is
|
|
// all STRING zeros with `percent` entirely ABSENT — exactly what the
|
|
// real sync path emits (never numeric zeros).
|
|
const curatedSnapshot = {
|
|
progress: {
|
|
total_phases: 5,
|
|
completed_phases: 5,
|
|
total_plans: 32,
|
|
completed_plans: 32,
|
|
percent: 100,
|
|
},
|
|
};
|
|
const zeroedPostFm = {
|
|
progress: {
|
|
total_phases: '0',
|
|
completed_phases: '0',
|
|
total_plans: '0',
|
|
completed_plans: '0',
|
|
},
|
|
};
|
|
const unchangedBodyDeltas = {
|
|
status: { pre: 'x', post: 'x' },
|
|
stopped_at: { pre: 'x', post: 'x' },
|
|
current_phase_name: { pre: 'x', post: 'x' },
|
|
paused_at: { pre: 'x', post: 'x' },
|
|
current_phase: { pre: 'x', post: 'x' },
|
|
current_plan: { pre: 'x', post: 'x' },
|
|
last_activity_desc: { pre: 'x', post: 'x' },
|
|
};
|
|
|
|
const r = applyStatePreservation({
|
|
transaction: openStateTransaction({
|
|
snapshot: curatedSnapshot,
|
|
resync: true,
|
|
bodyDeltas: unchangedBodyDeltas,
|
|
}),
|
|
postFm: zeroedPostFm,
|
|
});
|
|
|
|
// FAILS TODAY (#3756/#3871): the preserve-always executor for `progress`
|
|
// only ever consults `ctx.preFm` (always null on a resyncing write, per
|
|
// the root-cause comment above), so it returns immediately and the
|
|
// curated block above is NOT restored — `r.postFm.progress` stays the
|
|
// zeroed/percent-less disk-derived block instead.
|
|
assert.deepStrictEqual(
|
|
r.postFm.progress,
|
|
curatedSnapshot.progress,
|
|
'preserve-always for `progress` must restore the curated snapshot on a resyncing write, not just a non-resyncing one (#3756/#3871)',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// ADR-3473 §8.6 test matrix (.gsd/phase/feat-3871-state-transaction-snapshot/
|
|
// 50-test-matrix.md), rows 13-27 and 36: construction failures for
|
|
// `openStateTransaction` / `rebuildStateTransaction`, the legal-empty /
|
|
// null-prototype / prototype-pollution snapshot boundary, the aliasing fix
|
|
// (cloneCurated), the mutated-only-on-real-change fix, and the
|
|
// measured-vs-unmeasured coercion boundary that `applyPreserveAlways`'s
|
|
// `scanMeasuredSomething` decides on. Every call below wires
|
|
// `neutralBodyDeltasForMatrix()` on the transaction (not on the
|
|
// `applyStatePreservation` input — `bodyDeltas` is a TRANSACTION field,
|
|
// read via `transaction.bodyDeltas`) so a probe of ONE field never trips
|
|
// the §8.2 unwired-row throw for an unrelated preserve-when-unchanged row.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// Wires every currently-declared preserve-when-unchanged field with a
|
|
// neutral "unchanged this write" delta — local copy of the same pattern
|
|
// `neutralBodyDeltas()` (below, hoisted) already established in this file,
|
|
// kept separately named so this matrix block reads standalone.
|
|
function neutralBodyDeltasForMatrix() {
|
|
const deltas = {};
|
|
for (const [field, cls] of Object.entries(FIELD_CLASSIFICATION)) {
|
|
if (cls.preservation === 'preserve-when-unchanged') {
|
|
deltas[field] = { pre: 'unchanged-source', post: 'unchanged-source' };
|
|
}
|
|
}
|
|
return deltas;
|
|
}
|
|
|
|
describe('ADR-3473 §8.6 matrix rows 18-21: construction is a typed failure, distinct from a legal empty snapshot', () => {
|
|
test('openWithoutSnapshotIsAConstructionFailure', () => {
|
|
assert.throws(
|
|
() => openStateTransaction({}),
|
|
(err) => {
|
|
assert.strictEqual(
|
|
Object.prototype.hasOwnProperty.call(err, 'code'),
|
|
true,
|
|
'error must carry an own `code` property',
|
|
);
|
|
assert.strictEqual(err.code, 'STATE_TRANSACTION_SNAPSHOT_REQUIRED');
|
|
assert.strictEqual(err.constructorName, 'openStateTransaction', 'the typed field must name the constructor, not just the message prose');
|
|
return true;
|
|
},
|
|
'open({}) — no snapshot key at all — must throw the typed construction failure',
|
|
);
|
|
});
|
|
|
|
test('openWithNullSnapshotIsAConstructionFailure', () => {
|
|
for (const snapshot of [null, undefined]) {
|
|
assert.throws(
|
|
() => openStateTransaction({ snapshot }),
|
|
(err) => {
|
|
assert.strictEqual(err.code, 'STATE_TRANSACTION_SNAPSHOT_REQUIRED');
|
|
assert.strictEqual(err.constructorName, 'openStateTransaction');
|
|
return true;
|
|
},
|
|
`open({snapshot: ${JSON.stringify(snapshot)}}) must throw the typed construction failure`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('rebuildWithNullSnapshotIsAConstructionFailure', () => {
|
|
// The ADR's explicit wording: BOTH constructors, not just open().
|
|
for (const init of [{ snapshot: null }, { snapshot: undefined }, {}]) {
|
|
assert.throws(
|
|
() => rebuildStateTransaction(init),
|
|
(err) => {
|
|
assert.strictEqual(err.code, 'STATE_TRANSACTION_SNAPSHOT_REQUIRED');
|
|
assert.strictEqual(err.constructorName, 'rebuildStateTransaction', 'rebuild()\'s own typed failure must name rebuildStateTransaction, not open');
|
|
return true;
|
|
},
|
|
`rebuildStateTransaction(${JSON.stringify(init)}) must throw the typed construction failure`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('openRejectsNonObjectSnapshot', () => {
|
|
for (const snapshot of [[], 'str', 42]) {
|
|
assert.throws(
|
|
() => openStateTransaction({ snapshot }),
|
|
(err) => {
|
|
assert.strictEqual(err.code, 'STATE_TRANSACTION_SNAPSHOT_REQUIRED');
|
|
assert.strictEqual(err.constructorName, 'openStateTransaction');
|
|
return true;
|
|
},
|
|
`open({snapshot: ${JSON.stringify(snapshot)}}) — a non-object (array/string/number) snapshot — must throw`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('ADR-3473 §8.6 matrix rows 22-24: an empty / null-prototype / pollution-carrying snapshot is LEGAL', () => {
|
|
// This is as load-bearing as the throws above: it is what keeps
|
|
// /gsd-health --repair working on a broken (frontmatter-less) STATE.md —
|
|
// extractFrontmatter returns `{}` for such a document, never null, and
|
|
// `{}` must be accepted, not rejected as "absent".
|
|
test('emptySnapshotIsLegalAndRestoresNothing', () => {
|
|
const tx = openStateTransaction({ snapshot: {}, bodyDeltas: neutralBodyDeltasForMatrix() });
|
|
assert.strictEqual(tx.kind, 'open');
|
|
const r = applyStatePreservation({ transaction: tx, postFm: { status: 'executing' } });
|
|
assert.strictEqual(r.mutated, false, 'an empty snapshot must find nothing to restore — mutated stays false');
|
|
assert.strictEqual(r.postFm.status, 'executing', 'the derived value is left exactly as it was');
|
|
});
|
|
|
|
test('nullPrototypeSnapshotIsAccepted', () => {
|
|
const nullProtoSnapshot = Object.create(null);
|
|
nullProtoSnapshot.status = 'executing';
|
|
// Legal at construction — must not throw merely for lacking Object.prototype.
|
|
const tx = openStateTransaction({ snapshot: nullProtoSnapshot, bodyDeltas: neutralBodyDeltasForMatrix() });
|
|
assert.strictEqual(tx.snapshot.status, 'executing', 'a null-prototype snapshot must still support ordinary property lookup');
|
|
// And it must not break hasOwnProperty-style lookups inside the dispatch
|
|
// loop either — the call must complete and report what it did.
|
|
const r = applyStatePreservation({ transaction: tx, postFm: {} });
|
|
assert.strictEqual(typeof r.mutated, 'boolean');
|
|
});
|
|
|
|
test('snapshotWithPrototypeKeysDoesNotPollute', () => {
|
|
// Parsed via JSON.parse (not an object literal) so `__proto__` really is
|
|
// an OWN enumerable property of the snapshot, not the object's actual
|
|
// prototype link — the hostile shape a malformed/adversarial frontmatter
|
|
// parse could produce.
|
|
const evilSnapshot = JSON.parse('{"__proto__": {"polluted": true}, "constructor": "not-a-function", "toString": "not-a-method"}');
|
|
assert.strictEqual(
|
|
Object.prototype.hasOwnProperty.call(evilSnapshot, '__proto__'),
|
|
true,
|
|
'precondition: __proto__ must be an OWN key of the parsed snapshot, not the prototype link',
|
|
);
|
|
|
|
const tx = openStateTransaction({ snapshot: evilSnapshot, bodyDeltas: neutralBodyDeltasForMatrix() });
|
|
applyStatePreservation({ transaction: tx, postFm: {} });
|
|
|
|
assert.strictEqual(({}).polluted, undefined, 'a fresh object literal must not have picked up a polluted prototype property');
|
|
assert.strictEqual(
|
|
Object.prototype.hasOwnProperty.call(Object.prototype, 'polluted'),
|
|
false,
|
|
'Object.prototype itself must not have gained an own `polluted` property',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('ADR-3473 §8.6 matrix row 27: the transaction object is frozen', () => {
|
|
test('transactionSnapshotIsFrozen', () => {
|
|
const tx = openStateTransaction({ snapshot: { status: 'executing' } });
|
|
assert.strictEqual(Object.isFrozen(tx), true, 'the transaction object itself must be frozen');
|
|
// NOTE (verified against the implementation, not assumed): only the
|
|
// transaction OBJECT is frozen at construction — `createStateTransaction`
|
|
// never calls Object.freeze on `snapshot` itself. So `tx.snapshot` is NOT
|
|
// frozen (Object.isFrozen(tx.snapshot) === false); what independence the
|
|
// snapshot enjoys against later mutation comes from `applyPreserveAlways`
|
|
// cloning on restore (see restoreClonesSoTheSnapshotCannotBeMutatedThroughPostFm
|
|
// below), not from freezing the snapshot object. Assigning `tx.snapshot`
|
|
// itself throws because this test file runs in strict mode ('use strict'
|
|
// at the top) and `tx` is frozen — a sloppy-mode module would instead
|
|
// silently no-op the assignment.
|
|
assert.strictEqual(Object.isFrozen(tx.snapshot), false, 'the snapshot OBJECT is not itself frozen by construction — only the transaction wrapper is');
|
|
assert.throws(
|
|
() => { tx.snapshot = { replaced: true }; },
|
|
TypeError,
|
|
'assigning to a frozen transaction\'s property must throw under strict mode, and must not take effect',
|
|
);
|
|
assert.strictEqual(tx.snapshot.status, 'executing', 'the original snapshot value must be unchanged after the failed assignment attempt');
|
|
});
|
|
});
|
|
|
|
describe('ADR-3473 §8.6 matrix rows 25-26: restore mutation-reporting and aliasing independence', () => {
|
|
test('identicalRestoreDoesNotReportMutation', () => {
|
|
const curatedProgress = { total_phases: 5, completed_phases: 5, total_plans: 32, completed_plans: 32, percent: 100 };
|
|
const tx = openStateTransaction({
|
|
snapshot: { progress: { ...curatedProgress } },
|
|
resync: false,
|
|
bodyDeltas: neutralBodyDeltasForMatrix(),
|
|
});
|
|
const r = applyStatePreservation({ transaction: tx, postFm: { progress: { ...curatedProgress } } });
|
|
assert.strictEqual(r.mutated, false, 'restoring a value already identical to what postFm held must not report mutated=true (no spurious no-op write)');
|
|
});
|
|
|
|
test('restoreClonesSoTheSnapshotCannotBeMutatedThroughPostFm', () => {
|
|
const curatedSnapshot = { progress: { total_phases: 5, completed_phases: 5, total_plans: 32, completed_plans: 32, percent: 100 } };
|
|
const tx = openStateTransaction({
|
|
snapshot: curatedSnapshot,
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltasForMatrix(),
|
|
});
|
|
// Derived measured NOTHING (all-string-zero, percent absent) — the
|
|
// #3756 shape — so preserve-always restores the curated block wholesale.
|
|
const postFm = { progress: { total_phases: '0', completed_phases: '0', total_plans: '0', completed_plans: '0' } };
|
|
const r = applyStatePreservation({ transaction: tx, postFm });
|
|
assert.strictEqual(r.mutated, true, 'precondition: the restore must actually have happened');
|
|
assert.notStrictEqual(r.postFm.progress, tx.snapshot.progress, 'the restored value must be a CLONE, not the same object reference as the snapshot');
|
|
|
|
// Mutate the restored postFm.progress block IN PLACE, as a later step
|
|
// in the same write pipeline legitimately could.
|
|
r.postFm.progress.total_phases = 999;
|
|
r.postFm.progress.percent = 1;
|
|
|
|
assert.strictEqual(tx.snapshot.progress.total_phases, 5, 'the transaction\'s OWN snapshot must be unaffected by a later in-place mutation of postFm.progress');
|
|
assert.strictEqual(tx.snapshot.progress.percent, 100, 'same independence check on a second nested field');
|
|
});
|
|
});
|
|
|
|
describe('ADR-3473 §8.6 matrix rows 13-17 (+10/11 pinned): the measured-vs-unmeasured coercion boundary', () => {
|
|
// Every case here drives applyPreserveAlways through applyStatePreservation
|
|
// with resync:true and a real curated `progress` block, varying ONLY the
|
|
// derived totals — the boundary the design/matrix calls out as the
|
|
// riskiest logic in the change (`scanMeasuredSomething`'s `toFiniteNumber`
|
|
// coercion, never a raw `=== 0`).
|
|
const curatedProgress = { total_phases: 5, completed_phases: 5, total_plans: 32, completed_plans: 32, percent: 100 };
|
|
|
|
function restoreWithDerivedTotals(derivedProgress) {
|
|
const tx = openStateTransaction({
|
|
snapshot: { progress: { ...curatedProgress } },
|
|
resync: true,
|
|
bodyDeltas: neutralBodyDeltasForMatrix(),
|
|
});
|
|
return applyStatePreservation({ transaction: tx, postFm: { progress: derivedProgress } });
|
|
}
|
|
|
|
test('stringZeroTotalsAreTreatedAsUnmeasured', () => {
|
|
// limit-1/limit/limit+1 triple on the STRING shape production actually
|
|
// emits ("0", not 0) — numeric coercion, never `=== 0`.
|
|
for (const zeroish of ['0', '00', '0.0']) {
|
|
const r = restoreWithDerivedTotals({ total_phases: zeroish, total_plans: zeroish, completed_phases: '0', completed_plans: '0' });
|
|
assert.deepStrictEqual(r.postFm.progress, curatedProgress, `derived totals ${JSON.stringify(zeroish)} must be treated as unmeasured — curated block stands`);
|
|
assert.strictEqual(r.mutated, true);
|
|
}
|
|
});
|
|
|
|
test('stringNonZeroTotalIsMeasured', () => {
|
|
const r = restoreWithDerivedTotals({ total_phases: '1', total_plans: '0', completed_phases: '0', completed_plans: '0' });
|
|
assert.strictEqual(r.mutated, false, 'a measured scan (total_phases:"1") must win — derived stands untouched');
|
|
assert.deepStrictEqual(r.postFm.progress, { total_phases: '1', total_plans: '0', completed_phases: '0', completed_plans: '0' });
|
|
});
|
|
|
|
test('absentTotalsAreUnmeasured', () => {
|
|
for (const derived of [{}, { total_phases: null, total_plans: null }, { total_phases: undefined, total_plans: undefined }, { total_phases: '', total_plans: '' }]) {
|
|
const r = restoreWithDerivedTotals(derived);
|
|
assert.deepStrictEqual(r.postFm.progress, curatedProgress, `derived ${JSON.stringify(derived)} (absent/null/undefined/empty totals) must be unmeasured — curated stands`);
|
|
}
|
|
});
|
|
|
|
test('nonNumericTotalsDegradeToPreservation', () => {
|
|
for (const derived of [
|
|
{ total_phases: 'abc', total_plans: 'abc' },
|
|
{ total_phases: NaN, total_plans: NaN },
|
|
{ total_phases: {}, total_plans: {} },
|
|
{ total_phases: [], total_plans: [] },
|
|
]) {
|
|
const r = restoreWithDerivedTotals(derived);
|
|
assert.deepStrictEqual(r.postFm.progress, curatedProgress, `hostile derived totals ${JSON.stringify(derived)} must degrade TOWARD preservation, never toward deletion`);
|
|
}
|
|
});
|
|
|
|
test('negativeTotalsAreNotAMeasurement', () => {
|
|
const r = restoreWithDerivedTotals({ total_phases: -1, total_plans: -1 });
|
|
assert.deepStrictEqual(r.postFm.progress, curatedProgress, 'a negative total is not a valid count — unmeasured — curated stands');
|
|
});
|
|
|
|
// Pinned mirror pair from the design's row 11/the existing matrix's row
|
|
// 10 — grouped here so the boundary (only both-zero is unmeasured) reads
|
|
// legibly against the coercion cases above.
|
|
test('oneNonZeroTotalCountsAsMeasuredEitherDirection', () => {
|
|
const r1 = restoreWithDerivedTotals({ total_phases: 1, total_plans: 0, completed_phases: 0, completed_plans: 0 });
|
|
assert.strictEqual(r1.mutated, false, 'total_phases:1, total_plans:0 must count as measured');
|
|
assert.deepStrictEqual(r1.postFm.progress, { total_phases: 1, total_plans: 0, completed_phases: 0, completed_plans: 0 });
|
|
|
|
const r2 = restoreWithDerivedTotals({ total_phases: 0, total_plans: 1, completed_phases: 0, completed_plans: 0 });
|
|
assert.strictEqual(r2.mutated, false, 'total_phases:0, total_plans:1 must count as measured (the mirror) — only both-zero is unmeasured');
|
|
assert.deepStrictEqual(r2.postFm.progress, { total_phases: 0, total_plans: 1, completed_phases: 0, completed_plans: 0 });
|
|
});
|
|
});
|
|
|
|
describe('ADR-3473 §8.6 matrix row 36: property — preservation never drops a curated key the derived block lacks', () => {
|
|
// Scoped to `progress`, the one preserve-always/progress-ratchet field
|
|
// this phase's centerpiece logic governs (this file's other property test,
|
|
// "#3468 matrix C3" above, already covers the generic preserve-when-
|
|
// unchanged dispatch as a property — a whitespace/empty-string curated
|
|
// value is deliberately NOT restored there, by long-standing design, so a
|
|
// property phrased over that generic field set would produce a false
|
|
// failure unrelated to this phase's change).
|
|
const progressCounterArb = fc.oneof(
|
|
fc.integer({ min: 0, max: 999 }),
|
|
fc.integer({ min: 0, max: 999 }).map(String),
|
|
);
|
|
const progressBlockArb = fc.record({
|
|
total_phases: progressCounterArb,
|
|
completed_phases: progressCounterArb,
|
|
total_plans: progressCounterArb,
|
|
completed_plans: progressCounterArb,
|
|
percent: progressCounterArb,
|
|
});
|
|
|
|
test('preservationNeverDropsACuratedKey', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.option(progressBlockArb, { nil: undefined }),
|
|
fc.option(progressBlockArb, { nil: undefined }),
|
|
fc.boolean(),
|
|
(curatedProgress, derivedProgress, resync) => {
|
|
const snapshot = curatedProgress !== undefined ? { progress: curatedProgress } : {};
|
|
const postFm = derivedProgress !== undefined ? { progress: derivedProgress } : {};
|
|
const tx = openStateTransaction({ snapshot, resync, bodyDeltas: neutralBodyDeltasForMatrix() });
|
|
const r = applyStatePreservation({ transaction: tx, postFm });
|
|
|
|
if (curatedProgress !== undefined && derivedProgress === undefined) {
|
|
if (!Object.prototype.hasOwnProperty.call(r.postFm, 'progress')) {
|
|
throw new Error(
|
|
`curated key 'progress' dropped: curated=${JSON.stringify(curatedProgress)} ` +
|
|
`derived=${JSON.stringify(derivedProgress)} resync=${resync} result=${JSON.stringify(r.postFm)}`,
|
|
);
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
),
|
|
// Seeded and bounded per repo convention; replay data (the exact
|
|
// curated/derived/resync triple) is printed via the thrown Error
|
|
// above on any failure.
|
|
{ seed: 3871, numRuns: 300 },
|
|
);
|
|
});
|
|
});
|