fix(#3699): report why a derived frontmatter key was not written, and repair a missing body source (#3846)
* test(#3699): failing-first coverage for derived-key reporting and the case-D fallback * fix(#3699): report why a derived frontmatter key was not written, and repair a missing body source * fix(#3699): scope session-field writes to ## Session so an archived line cannot absorb the update * fix(#3699): resolve the session writer from body labels only, so a frontmatter key never writes the body * chore(#3699): backfill changeset PR number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/noble-foxes-gather.md
Normal file
5
.changeset/noble-foxes-gather.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3846
|
||||
---
|
||||
**`state update` now explains why a field was not written, instead of reporting it as absent** — asking to update a frontmatter key such as `stopped_at` returned "not found in STATE.md", byte-identical to a genuinely missing field and pointing away from the body field that does work. The refusal now names the body source, or names what derives the key when it has no body source. A document whose frontmatter carries a key with no body source at all — previously unrepairable through this command — can now be fixed by writing the key directly, reported as `wrote: "frontmatter"`. (#3699)
|
||||
@@ -45,7 +45,8 @@ node gsd-tools.cjs state load
|
||||
# Output STATE.md frontmatter as JSON
|
||||
node gsd-tools.cjs state json
|
||||
|
||||
# Update a single field
|
||||
# Update a single field. Frontmatter keys are projections of body fields —
|
||||
# write the body field (see COMMANDS.md#state-update-field-value).
|
||||
node gsd-tools.cjs state update <field> <value>
|
||||
|
||||
# Get STATE.md content or a specific section
|
||||
|
||||
@@ -2012,6 +2012,64 @@ Presence and posture are separate verdicts: a missing agent is reported in `miss
|
||||
|
||||
---
|
||||
|
||||
### `state update <field> <value>`
|
||||
|
||||
Update a single STATE.md field.
|
||||
|
||||
**Prerequisites:** `.planning/STATE.md` exists
|
||||
**Produces:** `{updated: true, preserved: [...]}`, or `{updated: false, reason, preserved: [...]}`
|
||||
|
||||
```bash
|
||||
node gsd-tools.cjs state update "Stopped at" "finished the migration"
|
||||
```
|
||||
|
||||
#### Frontmatter keys are projections — write the body field
|
||||
|
||||
STATE.md's frontmatter is **re-derived from the body on every write**. So a frontmatter key like
|
||||
`stopped_at` is not the value; it is a projection of the body field `Stopped at:`. Ask to update the
|
||||
key and the command refuses, naming the field that does work:
|
||||
|
||||
```json
|
||||
{
|
||||
"updated": false,
|
||||
"reason": "Field \"stopped_at\" is a body-derived frontmatter key and is not directly writable. Update its body source instead: state update \"Stopped At\" <value>."
|
||||
}
|
||||
```
|
||||
|
||||
This is distinct from a field that genuinely is not there, which still reports
|
||||
`Field "…" not found in STATE.md`. The two used to be indistinguishable (#3699).
|
||||
|
||||
| Frontmatter key | Body source |
|
||||
|---|---|
|
||||
| `current_phase` | `Current Phase` (or the prose `Phase:` line) |
|
||||
| `current_phase_name` | `Current Phase Name` (or the prose `Phase:` name) |
|
||||
| `current_plan` | `Current Plan` |
|
||||
| `status` | `Status` |
|
||||
| `stopped_at` | `Stopped At` / `Stopped at` (under `## Session`) |
|
||||
| `paused_at` | `Paused At` (under `## Session`) |
|
||||
| `last_activity` | `Last Activity` / `Last activity` (date part) |
|
||||
| `last_activity_desc` | `Last Activity Description` (or the prose after the dash) |
|
||||
|
||||
Keys not in this table have no body source at all — `milestone` and `milestone_name` come from
|
||||
ROADMAP.md, `progress.*` from a scan of `.planning/phases/`, and `last_updated` / `state_head` /
|
||||
`gsd_state_version` are recomputed on every write. The refusal names which of those applies.
|
||||
|
||||
#### Repairing a document whose body source is missing
|
||||
|
||||
If frontmatter carries a key but the body has **no** source line for it, there is nothing to derive
|
||||
from and neither route can write. In that one case the frontmatter key *is* directly writable, and
|
||||
the command says so:
|
||||
|
||||
```json
|
||||
{ "updated": true, "wrote": "frontmatter", "preserved": [] }
|
||||
```
|
||||
|
||||
`wrote: "frontmatter"` appears only on this repair path — an ordinary update omits it. The fallback
|
||||
is deliberately narrow: it will not fire while any body source line still exists (even one stranded
|
||||
in an archive section), and it will not invent a frontmatter key that is not already there.
|
||||
|
||||
---
|
||||
|
||||
### `state validate`
|
||||
|
||||
Detect drift between STATE.md and the actual filesystem.
|
||||
|
||||
@@ -142,6 +142,115 @@ export const FIELD_CLASSIFICATION: Readonly<Record<string, FieldClassification>>
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Which BODY field feeds each frontmatter key.
|
||||
*
|
||||
* `FIELD_CLASSIFICATION` above answers "who wins when frontmatter and body
|
||||
* disagree"; this answers "and what is the body one called". They are separate
|
||||
* questions and this one is display/routing knowledge, not preservation policy,
|
||||
* so it does not widen the ADR-3408-governed table.
|
||||
*
|
||||
* #3699: `state update stopped_at …` reported `Field "stopped_at" not found in
|
||||
* STATE.md` — byte-identical to what a genuinely absent field reports. The key
|
||||
* IS present; it is a projection of a body field, and the message pointed away
|
||||
* from the route that works. Naming the source is what makes the two cases
|
||||
* distinguishable.
|
||||
*
|
||||
* Transcribed from `buildStateFrontmatter` (`state.cts`), which is the real
|
||||
* deriver. That makes this a SECOND copy of knowledge that already exists, so it
|
||||
* ships with a parity test asserting this key set equals the body-derived key set
|
||||
* the builder actually emits (CLAUDE.md → Generative Fix Divergence). Keys the
|
||||
* builder derives from disk, an external file, or the clock have no body source
|
||||
* and are deliberately ABSENT here rather than mapped to a lie.
|
||||
*/
|
||||
export const FRONTMATTER_BODY_SOURCE: Readonly<Record<string, readonly string[]>> = Object.freeze(
|
||||
Object.assign(Object.create(null) as Record<string, readonly string[]>, {
|
||||
current_phase: Object.freeze(['Current Phase']),
|
||||
current_phase_name: Object.freeze(['Current Phase Name']),
|
||||
current_plan: Object.freeze(['Current Plan']),
|
||||
status: Object.freeze(['Status']),
|
||||
// Scoped to `## Session` by the builder; see the presence check in
|
||||
// `updateCore` for why the lookup here is deliberately unscoped.
|
||||
stopped_at: Object.freeze(['Stopped At', 'Stopped at']),
|
||||
paused_at: Object.freeze(['Paused At']),
|
||||
last_activity: Object.freeze(['Last Activity', 'Last activity']),
|
||||
last_activity_desc: Object.freeze(['Last Activity Description']),
|
||||
} satisfies Record<string, readonly string[]>),
|
||||
);
|
||||
|
||||
/**
|
||||
* The frontmatter keys whose body source lives inside `## Session`.
|
||||
*
|
||||
* #3374 established that these fields must be written where the reader reads
|
||||
* them: `buildStateFrontmatter` harvests `Stopped At` / `Paused At` from the
|
||||
* session section only, so a whole-body replace "lets a decoy `**Stopped at:**`
|
||||
* line in an unrelated (e.g. archive) section absorb the refresh while the
|
||||
* harvested session value stays stale" (`stateReplaceFieldInSession`'s own
|
||||
* docstring). `updateCore` was still doing the whole-body replace.
|
||||
*/
|
||||
const SESSION_SCOPED_KEYS: ReadonlySet<string> = new Set(['stopped_at', 'paused_at']);
|
||||
|
||||
/**
|
||||
* The `(primary, fallback)` label pair for a session-scoped frontmatter KEY.
|
||||
*/
|
||||
function sessionLabelsForKey(key: string): { primary: string; fallback: string | null } | null {
|
||||
if (!SESSION_SCOPED_KEYS.has(key)) return null;
|
||||
const labels = FRONTMATTER_BODY_SOURCE[key];
|
||||
return { primary: labels[0], fallback: labels[1] ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The same pair, resolved from a BODY LABEL the caller named (`Stopped At`,
|
||||
* `Stopped at`, `Paused At`). `null` for anything else.
|
||||
*
|
||||
* Deliberately does NOT accept a frontmatter key. An earlier cut resolved both
|
||||
* spellings through one function and used it for the write, which made
|
||||
* `state update stopped_at …` write the BODY line through the session writer —
|
||||
* silently defeating the "frontmatter keys are not directly writable" contract
|
||||
* this whole change exists to state, and reporting `updated: false` while having
|
||||
* written. The write may only ever be reached by naming a body field.
|
||||
*/
|
||||
function sessionLabelsForBodyField(field: string): { primary: string; fallback: string | null } | null {
|
||||
const key = frontmatterKeyForBodyField(field);
|
||||
return key === null ? null : sessionLabelsForKey(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Would a session-scoped write actually land? Asks by attempting the real write
|
||||
* with a throwaway value and seeing whether anything moved.
|
||||
*
|
||||
* Deliberately reuses the writer rather than re-deriving "where is the session
|
||||
* section" — a separate scope check could disagree with the writer, and a
|
||||
* presence check that disagrees with the write it guards is the whole bug class
|
||||
* here. `stateReplaceFieldInSession` is replace-only and pure, so probing costs
|
||||
* nothing and the result is discarded.
|
||||
*/
|
||||
function sessionSourceExists(body: string, labels: { primary: string; fallback: string | null }): boolean {
|
||||
return stateReplaceFieldInSession(body, labels.primary, labels.fallback, '\u0000probe') !== body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Own-property body-source lookup. `null` for a key with no body source (a
|
||||
* disk/external/clock-derived key) and for anything not a frontmatter key.
|
||||
*/
|
||||
export function getFrontmatterBodySource(field: string): readonly string[] | null {
|
||||
if (!Object.prototype.hasOwnProperty.call(FRONTMATTER_BODY_SOURCE, field)) return null;
|
||||
return FRONTMATTER_BODY_SOURCE[field];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse lookup: the frontmatter key a body field feeds, or `null`.
|
||||
* Lets a failed body-field update name the frontmatter key that still carries a
|
||||
* value (#3699 case D), instead of reporting a bare absence.
|
||||
*/
|
||||
export function frontmatterKeyForBodyField(bodyField: string): string | null {
|
||||
const wanted = bodyField.trim().toLowerCase();
|
||||
for (const key of Object.keys(FRONTMATTER_BODY_SOURCE)) {
|
||||
if (FRONTMATTER_BODY_SOURCE[key].some((f) => f.toLowerCase() === wanted)) return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Own-property classification lookup. Returns `null` for unknown fields
|
||||
* (including inherited prototype methods like `toString`/`valueOf`).
|
||||
@@ -1834,8 +1943,69 @@ function updateCore(
|
||||
const existingFm = extractFrontmatter(content) as Record<string, unknown>;
|
||||
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
||||
const body = stripFrontmatter(content);
|
||||
const result = stateReplaceField(body, intent.field, intent.value);
|
||||
// #3699 review: session-scoped fields are written through the session-scoped
|
||||
// writer. A whole-body `stateReplaceField` matches the FIRST occurrence
|
||||
// anywhere, so with no `Stopped At:` line in `## Session` but a stale one in
|
||||
// `## Session Continuity Archive`, `state update "Stopped At" …` reported
|
||||
// `updated: true` while rewriting the ARCHIVE line and leaving both the session
|
||||
// section and the `stopped_at` frontmatter key untouched — a silent corruption
|
||||
// of a historical record reported as success. #3374 already established this
|
||||
// rule for the other writer; this one had not adopted it.
|
||||
const sessionWriteLabels = sessionLabelsForBodyField(intent.field);
|
||||
let result: string | null;
|
||||
if (sessionWriteLabels) {
|
||||
// Replace-only by contract: unchanged content means the field is not in the
|
||||
// session section, which is a miss, not a write.
|
||||
const replaced = stateReplaceFieldInSession(body, sessionWriteLabels.primary, sessionWriteLabels.fallback, intent.value);
|
||||
result = replaced === body ? null : replaced;
|
||||
} else {
|
||||
result = stateReplaceField(body, intent.field, intent.value);
|
||||
}
|
||||
if (result === null) {
|
||||
// #3699 case D — the frontmatter fallback.
|
||||
//
|
||||
// Normally frontmatter keys are NOT writable here: they are projections, and
|
||||
// `buildStateFrontmatter` re-derives them from the body on every write, so a
|
||||
// direct frontmatter write would be discarded. But when the body source line
|
||||
// is absent entirely, there is nothing to derive FROM: the key's existing
|
||||
// value survives on `preserve-when-unchanged`, and neither the frontmatter
|
||||
// key nor the body field can be updated by any route. That document is
|
||||
// unrepairable through `state update`, which is the gap this closes.
|
||||
//
|
||||
// Deliberately narrow — all three must hold:
|
||||
// (1) the field is a frontmatter key with a known body source,
|
||||
// (2) NO body source line exists, so the body route is genuinely unavailable
|
||||
// (this is what keeps case A, where the body route works, routing to the
|
||||
// body as before), and
|
||||
// (3) the frontmatter already carries the key, so this updates a value that
|
||||
// is really there rather than inventing one.
|
||||
//
|
||||
// The presence check in (2) is UNSCOPED on purpose, unlike the builder's
|
||||
// `## Session` scoping for stopped_at/paused_at. The asymmetry is the safe
|
||||
// direction: any `Stopped at:` line anywhere in the body — including one in an
|
||||
// archive section — suppresses the fallback, so this never writes frontmatter
|
||||
// while a body line the user could edit still exists.
|
||||
const bodySource = getFrontmatterBodySource(intent.field);
|
||||
const frontmatterCarriesKey =
|
||||
hasFrontmatter && Object.prototype.hasOwnProperty.call(existingFm, intent.field);
|
||||
// The presence check asks the same question the WRITE asks, in the same
|
||||
// scope. An earlier cut checked the whole body on the reasoning that any
|
||||
// editable line should suppress the repair — but a line the reader never
|
||||
// reads is not a source, and suppressing on it left the document
|
||||
// unrepairable while pointing the user at a command that would rewrite the
|
||||
// wrong line. Same scope for read, write and probe, or they disagree.
|
||||
const sessionProbeLabels = sessionLabelsForKey(intent.field);
|
||||
const bodySourceExists = sessionProbeLabels
|
||||
? sessionSourceExists(body, sessionProbeLabels)
|
||||
: (bodySource ?? []).some((f) => stateExtractField(body, f) !== null);
|
||||
if (bodySource && frontmatterCarriesKey && !bodySourceExists) {
|
||||
const nextFm = { ...existingFm, [intent.field]: intent.value };
|
||||
return {
|
||||
content: `---\n${reconstructFrontmatter(nextFm as unknown as Frontmatter)}\n---\n\n${body}`,
|
||||
updated: [intent.field],
|
||||
data: { updated: true, wroteFrontmatter: true },
|
||||
};
|
||||
}
|
||||
return { content, updated: [], data: { updated: false } };
|
||||
}
|
||||
const reassembled = hasFrontmatter
|
||||
|
||||
104
src/state.cts
104
src/state.cts
@@ -64,6 +64,9 @@ import { findProjectRoot } from './project-root.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import milestoneLockMod = require('./milestone-lock.cjs');
|
||||
const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
|
||||
// #3699: the frontmatter-key <-> body-field routing behind `state update`'s
|
||||
// failure explanation, and the classification table it falls back to.
|
||||
const { getFieldClassification, getFrontmatterBodySource, frontmatterKeyForBodyField } = stateTransitionMod;
|
||||
type StateTransitionIntent = stateTransitionMod.StateTransitionIntent;
|
||||
type StateTransitionDeps = stateTransitionMod.StateTransitionDeps;
|
||||
type PhaseInventoryRecord = stateTransitionMod.PhaseInventoryRecord;
|
||||
@@ -584,6 +587,54 @@ function cmdStatePatch(cwd: string, patches: Record<string, string>, raw: boolea
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why did `state update <field>` not write anything?
|
||||
*
|
||||
* #3699: this used to be one sentence — `Field "X" not found in STATE.md` — for
|
||||
* every falsy outcome, so a PRESENT-but-derived frontmatter key and a genuinely
|
||||
* absent field produced byte-identical output apart from the name. The classifier
|
||||
* already knew the difference; the message threw it away, and worse, pointed away
|
||||
* from the route that works.
|
||||
*
|
||||
* Four distinct answers, because there are four distinct situations:
|
||||
* 1. a body-derived frontmatter key whose body source EXISTS → name that source
|
||||
* 2. a frontmatter key with no body source at all (disk/external/clock-derived)
|
||||
* → say what derives it, and do not invent a body field to blame
|
||||
* 3. a body field that feeds a frontmatter key still carrying a value
|
||||
* → name the key, so case D is diagnosable rather than a bare absence
|
||||
* 4. genuinely unknown → unchanged
|
||||
*/
|
||||
function explainUpdateFailure(field: string): string {
|
||||
const bodySource = getFrontmatterBodySource(field);
|
||||
if (bodySource) {
|
||||
// (1) The fallback in `updateCore` did not fire, so a body source line
|
||||
// exists — that is the writable route.
|
||||
const [primary] = bodySource;
|
||||
return `Field "${field}" is a body-derived frontmatter key and is not directly writable. `
|
||||
+ `Update its body source instead: state update "${primary}" <value>.`;
|
||||
}
|
||||
const classification = getFieldClassification(field);
|
||||
if (classification) {
|
||||
// (2) Known key, no body source: disk/external/free-derived.
|
||||
const derivedFrom: Record<string, string> = {
|
||||
disk: 'derived from a scan of .planning/phases/ and is not directly writable',
|
||||
external: 'derived from ROADMAP.md and is not directly writable',
|
||||
free: 'recomputed on every write and is not directly writable',
|
||||
curated: 'maintained by the write path and is not directly writable through this command',
|
||||
body: 'body-derived and is not directly writable',
|
||||
};
|
||||
return `Field "${field}" is a frontmatter key that is ${derivedFrom[classification.source]}.`;
|
||||
}
|
||||
const owningKey = frontmatterKeyForBodyField(field);
|
||||
if (owningKey) {
|
||||
// (3) Case D from the body-field side.
|
||||
return `Field "${field}" not found in STATE.md. It is the body source for frontmatter key `
|
||||
+ `"${owningKey}" — add the "${field}:" line to the body, or update "${owningKey}" directly `
|
||||
+ `to repair a document whose body source is missing.`;
|
||||
}
|
||||
return `Field "${field}" not found in STATE.md`; // (4) genuinely unknown
|
||||
}
|
||||
|
||||
function cmdStateUpdate(cwd: string, field: string | undefined, value: string | undefined): void {
|
||||
if (!field || value === undefined) {
|
||||
error('field and value required for state update');
|
||||
@@ -601,6 +652,27 @@ function cmdStateUpdate(cwd: string, field: string | undefined, value: string |
|
||||
try {
|
||||
let updated = false;
|
||||
let preSyncContent = '';
|
||||
let transitionData: Record<string, unknown> | undefined;
|
||||
// #3699 case D: when `updateCore` falls back to writing the frontmatter key
|
||||
// directly, the value must survive the post-sync pass — otherwise the write
|
||||
// is silently undone. `buildStateFrontmatter` re-derives `stopped_at` from
|
||||
// the body, finds no source, and emits nothing; `applyPreserveWhenUnchanged`
|
||||
// then sees an unchanged (absent) body source and restores the PRE-write
|
||||
// snapshot over the value just written. Verified: without this the command
|
||||
// reported `updated:false` with `preserved:["Stopped At"]` and the old value
|
||||
// stood.
|
||||
//
|
||||
// `authoritativeFm` is the seam built for exactly this (#2736 — "intent-first
|
||||
// frontmatter values … so the lossy body-prose re-derivation can never
|
||||
// destroy information the transition just resolved"), and it is re-applied
|
||||
// AFTER preservation (`applyPostSyncPreservation`), so it wins the restore.
|
||||
//
|
||||
// Populated by the transform below rather than up front, because only the
|
||||
// transition knows whether the fallback fired. Safe: `readModifyWriteStateMd`
|
||||
// dereferences `options.authoritativeFm` after running the transform. Left
|
||||
// empty when the fallback does not fire — an empty object iterates zero
|
||||
// entries and is a no-op at both application sites.
|
||||
const authoritativeFm: Record<string, unknown> = {};
|
||||
const divergedFields: string[] = [];
|
||||
const shouldResync = shouldResyncStateProgress([field as string]);
|
||||
// ADR-1769 Phase 7: dispatches to the STATE.md Transition Module. The
|
||||
@@ -616,9 +688,13 @@ function cmdStateUpdate(cwd: string, field: string | undefined, value: string |
|
||||
{ clock: realClock },
|
||||
);
|
||||
updated = (result.data as { updated: boolean } | undefined)?.updated === true;
|
||||
transitionData = result.data;
|
||||
if (transitionData?.wroteFrontmatter === true) {
|
||||
authoritativeFm[field as string] = value;
|
||||
}
|
||||
preSyncContent = result.content;
|
||||
return result.content;
|
||||
}, cwd, { resync: shouldResync, divergedFields });
|
||||
}, cwd, { resync: shouldResync, divergedFields, authoritativeFm });
|
||||
|
||||
// ADR-3408 §8.4 (D4): reconcile against the bytes actually persisted —
|
||||
// `updateCore`'s own match does not know whether sync/preservation later
|
||||
@@ -632,9 +708,31 @@ function cmdStateUpdate(cwd: string, field: string | undefined, value: string |
|
||||
const preserved = reconciled.filter((f) => f !== field);
|
||||
|
||||
if (updated) {
|
||||
output({ updated: true, preserved }, false, undefined);
|
||||
// #3699 case D: surfaced so a caller can tell "wrote the body source" from
|
||||
// "wrote the frontmatter key because no body source existed" — the second
|
||||
// is a repair, and silently reporting it as an ordinary update is the same
|
||||
// class of unfalsifiable success this issue is about.
|
||||
const wroteFrontmatter = (transitionData as { wroteFrontmatter?: boolean } | undefined)?.wroteFrontmatter === true;
|
||||
if (!wroteFrontmatter) {
|
||||
output({ updated: true, preserved }, false, undefined);
|
||||
} else {
|
||||
// `preserved` reports the BODY LABEL of each field preservation restored
|
||||
// (`bodyLabelFor`, the #3345 direction). In the case-D fallback that
|
||||
// reading is stale by one step: preservation DID restore this field's
|
||||
// snapshot, and `authoritativeFm` then overrode it, so the value on disk
|
||||
// is the one just written. Reporting it as preserved would claim a
|
||||
// restore that did not survive — the same unfalsifiable-success shape
|
||||
// #3699 is about, one field over. Drop this field's own labels; other
|
||||
// fields' preservation is untouched and still reported.
|
||||
const ownLabels = new Set((getFrontmatterBodySource(field as string) ?? []).map((l) => l.toLowerCase()));
|
||||
output({
|
||||
updated: true,
|
||||
wrote: 'frontmatter',
|
||||
preserved: preserved.filter((p) => !ownLabels.has(p.toLowerCase())),
|
||||
}, false, undefined);
|
||||
}
|
||||
} else {
|
||||
output({ updated: false, reason: `Field "${field as string}" not found in STATE.md`, preserved }, false, undefined);
|
||||
output({ updated: false, reason: explainUpdateFailure(field as string), preserved }, false, undefined);
|
||||
}
|
||||
} catch {
|
||||
output({ updated: false, reason: 'STATE.md not found' }, false, undefined);
|
||||
|
||||
@@ -1278,16 +1278,53 @@ describe('ADR-3408 §8.3(b) Matrix D: patchCore strips frontmatter first (#3469)
|
||||
assert.strictEqual(result.content, input);
|
||||
});
|
||||
|
||||
// D7 (independence, extend): updateCore is unchanged — it already strips
|
||||
// frontmatter first, the correct shape patchCore now matches. A
|
||||
// frontmatter-shaped `field` still cannot reach the YAML block through it.
|
||||
test('D7: updateCore is unchanged — a frontmatter-shaped field still cannot reach the YAML block', () => {
|
||||
const input = ['---', 'current_phase: "3"', '---', '', '# State', '', '**Status:** Planning', ''].join('\n');
|
||||
// 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);
|
||||
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
|
||||
|
||||
@@ -29,6 +29,8 @@ const fc = require('fast-check');
|
||||
const stateLib = require('../gsd-core/bin/lib/state.cjs');
|
||||
const stateTransitionMod = require('../gsd-core/bin/lib/state-transition.cjs');
|
||||
const stateDocument = require('../gsd-core/bin/lib/state-document.cjs');
|
||||
// #3699: the repo's one metacharacter-escape helper (local/no-adhoc-regex-escape).
|
||||
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
|
||||
const frontmatterLib = require('../gsd-core/bin/lib/frontmatter.cjs');
|
||||
const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs');
|
||||
const workstreamInventory = require('../gsd-core/bin/lib/workstream-inventory.cjs');
|
||||
@@ -16490,3 +16492,406 @@ describe('#3468 B8: a drifted / malformed / unparseable STATE.md never reaches t
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #3699 — `state update` told the truth about failure.
|
||||
//
|
||||
// A frontmatter key like `stopped_at` is a PROJECTION of a body field, and the
|
||||
// body is the source of truth. Asking to update the key used to return
|
||||
// `Field "stopped_at" not found in STATE.md` — byte-identical to what a
|
||||
// genuinely absent field returns, and pointing away from the route that works.
|
||||
//
|
||||
// Case D is the one real capability gap: frontmatter carries the key, the body
|
||||
// has no source line, and neither route can write. `updateCore` now falls back
|
||||
// to writing the frontmatter key directly there (and only there).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#3699 state update — derived frontmatter keys explain themselves', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createFixture();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
const FM = [
|
||||
'---',
|
||||
'gsd_state_version: 1.0',
|
||||
'current_phase: 1',
|
||||
'current_phase_name: alpha',
|
||||
'status: executing',
|
||||
'stopped_at: "original value"',
|
||||
'---',
|
||||
'',
|
||||
];
|
||||
const BODY = ['# Project State', '', '## Current Position', '', 'Phase: 1 (alpha)', 'Status: Executing', ''];
|
||||
const SESSION = ['## Session Continuity', '', 'Stopped at: original value', ''];
|
||||
|
||||
function writeState(lines, opts = {}) {
|
||||
const eol = opts.crlf ? '\r\n' : '\n';
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), lines.join(eol));
|
||||
}
|
||||
function update(field, value) {
|
||||
const result = runGsdTools(['state', 'update', field, value], tmpDir);
|
||||
return { result, output: JSON.parse(result.output) };
|
||||
}
|
||||
function stateText() {
|
||||
return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
}
|
||||
function frontmatterStoppedAt() {
|
||||
const m = stateText().match(/^stopped_at:.*$/m);
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
|
||||
// ── the headline defect: present-but-derived vs genuinely absent ───────────
|
||||
|
||||
test('a body-derived frontmatter key is reported as derived, and names its body source', () => {
|
||||
writeState([...FM, ...BODY, ...SESSION]);
|
||||
|
||||
const { output } = update('stopped_at', 'NEW VALUE');
|
||||
assert.strictEqual(output.updated, false);
|
||||
assert.match(output.reason, /not directly writable/i);
|
||||
assert.match(output.reason, /Stopped At/i, 'the reason must name the body source that DOES work');
|
||||
assert.doesNotMatch(output.reason, /not found in STATE\.md/i, 'the key is present — reporting absence is the bug');
|
||||
assert.match(frontmatterStoppedAt(), /original value/, 'a refused update must not write');
|
||||
});
|
||||
|
||||
test('a genuinely absent field still reports absence', () => {
|
||||
// The control that keeps the fix honest: if EVERY failure now says
|
||||
// "derived", the defect has been inverted, not closed.
|
||||
writeState([...FM, ...BODY, ...SESSION]);
|
||||
|
||||
const { output } = update('definitely_not_a_field', 'NEW VALUE');
|
||||
assert.strictEqual(output.updated, false);
|
||||
assert.strictEqual(output.reason, 'Field "definitely_not_a_field" not found in STATE.md');
|
||||
});
|
||||
|
||||
test('a present-but-derived key and a genuinely absent field no longer produce the same message', () => {
|
||||
// #3699 stated as a test: the two were byte-identical apart from the name.
|
||||
writeState([...FM, ...BODY, ...SESSION]);
|
||||
const derived = update('stopped_at', 'NEW VALUE').output.reason;
|
||||
|
||||
writeState([...FM, ...BODY, ...SESSION]);
|
||||
const absent = update('definitely_not_a_field', 'NEW VALUE').output.reason;
|
||||
|
||||
assert.notStrictEqual(
|
||||
derived.replace(/"stopped_at"/g, 'X'),
|
||||
absent.replace(/"definitely_not_a_field"/g, 'X'),
|
||||
'the two failures must be distinguishable by more than the field name',
|
||||
);
|
||||
});
|
||||
|
||||
test('the body source route still works and still syncs to frontmatter', () => {
|
||||
writeState([...FM, ...BODY, ...SESSION]);
|
||||
|
||||
const { output } = update('Stopped at', 'NEW VALUE');
|
||||
assert.strictEqual(output.updated, true);
|
||||
assert.match(frontmatterStoppedAt(), /NEW VALUE/);
|
||||
});
|
||||
|
||||
// ── case D: the capability gap ────────────────────────────────────────────
|
||||
|
||||
test('case D: with no body source, the frontmatter key becomes directly writable', () => {
|
||||
// Frontmatter carries stopped_at; the body has no `Stopped at:` line and no
|
||||
// `## Session` section. Before this, BOTH routes failed and the stale value
|
||||
// survived — the document was unrepairable through `state update`.
|
||||
writeState([...FM, ...BODY]);
|
||||
|
||||
const { output } = update('stopped_at', 'NEW VALUE');
|
||||
assert.strictEqual(output.updated, true);
|
||||
assert.strictEqual(output.wrote, 'frontmatter');
|
||||
assert.match(
|
||||
frontmatterStoppedAt(),
|
||||
/NEW VALUE/,
|
||||
'the value must survive syncStateFrontmatter + applyStatePreservation, not just be written by the transition',
|
||||
);
|
||||
});
|
||||
|
||||
test('case D: the fallback is idempotent across repeated writes', () => {
|
||||
writeState([...FM, ...BODY]);
|
||||
|
||||
update('stopped_at', 'FIRST');
|
||||
assert.match(frontmatterStoppedAt(), /FIRST/);
|
||||
const { output } = update('stopped_at', 'SECOND');
|
||||
assert.strictEqual(output.updated, true);
|
||||
assert.match(frontmatterStoppedAt(), /SECOND/);
|
||||
});
|
||||
|
||||
test('case D: preserved does not claim a restore that authoritativeFm overrode', () => {
|
||||
// Preservation DOES restore stopped_at's snapshot here (its body source is
|
||||
// unchanged — absent), and authoritativeFm then overrides it. Listing the
|
||||
// field in `preserved` would report a restore that did not survive: the
|
||||
// same unfalsifiable-success shape this issue is about, one field over.
|
||||
writeState([...FM, ...BODY]);
|
||||
|
||||
const { output } = update('stopped_at', 'NEW VALUE');
|
||||
const claimed = (output.preserved || []).map((p) => String(p).toLowerCase());
|
||||
assert.ok(
|
||||
!claimed.includes('stopped at') && !claimed.includes('stopped_at'),
|
||||
`preserved must not claim this field; got: ${JSON.stringify(output.preserved)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('case D via the body field name names the frontmatter key that still holds a value', () => {
|
||||
writeState([...FM, ...BODY]);
|
||||
|
||||
const { output } = update('Stopped at', 'NEW VALUE');
|
||||
assert.strictEqual(output.updated, false);
|
||||
assert.match(output.reason, /stopped_at/, 'the reason must name the frontmatter key carrying the value');
|
||||
});
|
||||
|
||||
// ── negative space: where the fallback must NOT fire ──────────────────────
|
||||
|
||||
test('the fallback does not fire when frontmatter does not carry the key', () => {
|
||||
// Nothing to repair — inventing a key here would be fabricating state.
|
||||
writeState([...FM.filter((l) => !l.startsWith('stopped_at:')), ...BODY]);
|
||||
|
||||
const { output } = update('stopped_at', 'NEW VALUE');
|
||||
assert.strictEqual(output.updated, false);
|
||||
assert.strictEqual(frontmatterStoppedAt(), null, 'no frontmatter key may be invented');
|
||||
});
|
||||
|
||||
test('a stale body-source line OUTSIDE ## Session is not treated as the source', () => {
|
||||
// Reversed from this change's first cut, on evidence. That cut suppressed the
|
||||
// repair whenever ANY body line existed, reasoning "prefer a line the user can
|
||||
// edit". But `buildStateFrontmatter` harvests Stopped At from `## Session`
|
||||
// ONLY, so an archive line is not a source — suppressing on it left the
|
||||
// document unrepairable AND pointed the user at a command that rewrote the
|
||||
// wrong line. Read scope, write scope and probe scope now all agree.
|
||||
writeState([
|
||||
...FM, ...BODY,
|
||||
'## Session', '', 'Notes: none', '',
|
||||
'## Session Continuity Archive', '', 'Stopped At: 2025-01-01 (old session)', '',
|
||||
]);
|
||||
|
||||
const { output } = update('stopped_at', '2026-08-24');
|
||||
assert.strictEqual(output.updated, true, 'an archive line must not block the repair');
|
||||
assert.strictEqual(output.wrote, 'frontmatter');
|
||||
assert.match(
|
||||
stateText(),
|
||||
/Stopped At: 2025-01-01 \(old session\)/,
|
||||
'the archived line is a historical record and must be left alone',
|
||||
);
|
||||
});
|
||||
|
||||
test('updating a session field never rewrites a line outside ## Session', () => {
|
||||
// The defect this guards: `stateReplaceField` matches the FIRST occurrence
|
||||
// anywhere in the body, so with no `Stopped At:` in `## Session` and a stale
|
||||
// one in the archive, the update reported success while silently rewriting
|
||||
// the archived record and leaving the real field untouched. #3374 established
|
||||
// the scoped writer for exactly this; `updateCore` had not adopted it.
|
||||
writeState([
|
||||
...FM, ...BODY,
|
||||
'## Session', '', 'Notes: none', '',
|
||||
'## Session Continuity Archive', '', 'Stopped At: 2025-01-01 (old session)', '',
|
||||
]);
|
||||
|
||||
const { output } = update('Stopped At', '2026-08-24');
|
||||
assert.strictEqual(output.updated, false, 'there is no Stopped At line in ## Session to write');
|
||||
assert.match(
|
||||
stateText(),
|
||||
/Stopped At: 2025-01-01 \(old session\)/,
|
||||
'the archived line must be byte-identical after a refused update',
|
||||
);
|
||||
assert.doesNotMatch(stateText(), /Stopped At: 2026-08-24/, 'nothing may have been written anywhere');
|
||||
});
|
||||
|
||||
test('a session field inside ## Session is still writable and still syncs', () => {
|
||||
// The complement: scoping must not break the normal route.
|
||||
writeState([...FM, ...BODY, '## Session', '', 'Stopped at: original value', '']);
|
||||
|
||||
const { output } = update('Stopped at', 'NEW VALUE');
|
||||
assert.strictEqual(output.updated, true);
|
||||
assert.match(stateText(), /^Stopped at: NEW VALUE$/m, 'the session line is the one that moved');
|
||||
assert.match(frontmatterStoppedAt(), /NEW VALUE/, 'and it synced to frontmatter');
|
||||
});
|
||||
|
||||
test('case D behaves identically on a CRLF document', () => {
|
||||
writeState([...FM, ...BODY], { crlf: true });
|
||||
|
||||
const { output } = update('stopped_at', 'NEW VALUE');
|
||||
assert.strictEqual(output.updated, true);
|
||||
assert.match(frontmatterStoppedAt(), /NEW VALUE/);
|
||||
});
|
||||
|
||||
// ── keys with no body source must not be given one ───────────────────────
|
||||
|
||||
test('keys derived from the clock, ROADMAP.md, or a disk scan say so instead of naming a body field', () => {
|
||||
const cases = [
|
||||
['last_updated', /recomputed on every write/i],
|
||||
['state_head', /recomputed on every write/i],
|
||||
['gsd_state_version', /recomputed on every write/i],
|
||||
['milestone', /ROADMAP\.md/i],
|
||||
['milestone_name', /ROADMAP\.md/i],
|
||||
['progress.percent', /scan of \.planning\/phases/i],
|
||||
['progress.total_plans', /scan of \.planning\/phases/i],
|
||||
];
|
||||
for (const [field, expected] of cases) {
|
||||
writeState([...FM, ...BODY, ...SESSION]);
|
||||
const { output } = update(field, 'X');
|
||||
assert.strictEqual(output.updated, false, `${field} must not be writable`);
|
||||
assert.match(output.reason, expected, `${field}: wrong derivation named`);
|
||||
assert.doesNotMatch(output.reason, /Update its body source/i, `${field} has no body source to name`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── the map cannot silently drift from the builder ───────────────────────
|
||||
|
||||
test('every FRONTMATTER_BODY_SOURCE entry actually round-trips from its body field', () => {
|
||||
// Real parity, per key. An earlier cut asserted only SET MEMBERSHIP against
|
||||
// the emitted frontmatter — near-vacuous, because buildStateFrontmatter emits
|
||||
// the whole schema key set regardless of body derivation, so a wrong mapping
|
||||
// would still pass.
|
||||
//
|
||||
// This drives each mapped key's own BODY LABEL to a distinct value and
|
||||
// asserts that value arrives in that frontmatter key. A mapping naming the
|
||||
// wrong body field cannot survive it.
|
||||
//
|
||||
// Two fixtures, because `paused_at` is not independent: normalizeStateStatus
|
||||
// forces `status: paused` whenever Paused At is set, so a single fixture
|
||||
// could not assert both `status` and `paused_at`.
|
||||
const expected = {
|
||||
current_phase: '7',
|
||||
current_phase_name: 'sentinel-name',
|
||||
current_plan: '3',
|
||||
status: 'executing', // normalized from the update below
|
||||
stopped_at: 'sentinel-stopped',
|
||||
last_activity: '2026-08-19',
|
||||
last_activity_desc: 'sentinel-desc',
|
||||
};
|
||||
|
||||
writeState([
|
||||
'---', 'gsd_state_version: 1.0', '---', '',
|
||||
'# Project State', '',
|
||||
'## Current Position', '',
|
||||
'Current Phase: 7',
|
||||
'Current Phase Name: sentinel-name',
|
||||
'Current Plan: 3',
|
||||
'Status: Planning', // deliberately != the update below, or the #948 no-op guard skips the sync
|
||||
'Last Activity: 2026-08-19',
|
||||
'Last Activity Description: sentinel-desc',
|
||||
'',
|
||||
'## Session', '',
|
||||
'Stopped at: sentinel-stopped',
|
||||
'',
|
||||
]);
|
||||
update('Status', 'Executing');
|
||||
|
||||
let fm = stateText().split('---')[1];
|
||||
assert.match(fm, /^last_updated:/m, 'precondition: the update must have actually synced frontmatter');
|
||||
|
||||
for (const [key, want] of Object.entries(expected)) {
|
||||
const hit = new RegExp(`^${key}:\\s*(.+)$`, 'm').exec(fm);
|
||||
assert.ok(hit, `${key} was not emitted from its mapped body field — the mapping is wrong`);
|
||||
assert.match(
|
||||
hit[1],
|
||||
new RegExp(escapeRegex(want)),
|
||||
`${key} did not carry the value written to its mapped body field`,
|
||||
);
|
||||
}
|
||||
|
||||
// paused_at, in its own fixture for the reason above.
|
||||
writeState([
|
||||
'---', 'gsd_state_version: 1.0', '---', '',
|
||||
'# Project State', '',
|
||||
'## Current Position', '',
|
||||
'Current Phase: 7',
|
||||
'Status: Planning',
|
||||
'',
|
||||
'## Session', '',
|
||||
'Paused At: sentinel-paused',
|
||||
'',
|
||||
]);
|
||||
update('Status', 'Executing');
|
||||
|
||||
fm = stateText().split('---')[1];
|
||||
const paused = /^paused_at:\s*(.+)$/m.exec(fm);
|
||||
assert.ok(paused, 'paused_at was not emitted from its mapped body field');
|
||||
assert.match(paused[1], /sentinel-paused/);
|
||||
|
||||
// And the fixtures above must have covered the whole map — otherwise a key
|
||||
// added to FRONTMATTER_BODY_SOURCE could go untested here forever.
|
||||
const covered = new Set([...Object.keys(expected), 'paused_at']);
|
||||
for (const key of Object.keys(stateTransitionMod.FRONTMATTER_BODY_SOURCE)) {
|
||||
assert.ok(covered.has(key), `FRONTMATTER_BODY_SOURCE maps "${key}" but this round-trip test does not exercise it`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no body-derived frontmatter key escapes FRONTMATTER_BODY_SOURCE', () => {
|
||||
// The reverse direction. Every key buildStateFrontmatter emits must be either
|
||||
// mapped, or a declared non-body-derived key. A NEW body-derived key added to
|
||||
// the builder without a map entry fails here.
|
||||
//
|
||||
// Known limit, stated rather than hidden: someone could add a key to the
|
||||
// exclusion set below instead of the map. That is a smaller and far more
|
||||
// visible edit than silently forgetting the map, which is what this guards.
|
||||
const NOT_BODY_DERIVED = new Set([
|
||||
'gsd_state_version', // schema constant
|
||||
'last_updated', 'state_head', // recomputed every write
|
||||
'milestone', 'milestone_name', // ROADMAP.md
|
||||
'progress', // disk scan
|
||||
]);
|
||||
|
||||
writeState([
|
||||
'---', 'gsd_state_version: 1.0', '---', '',
|
||||
'# Project State', '',
|
||||
'## Current Position', '',
|
||||
'Current Phase: 2', 'Current Phase Name: beta', 'Current Plan: 1',
|
||||
'Status: Planning',
|
||||
'Last Activity: 2026-08-19 — did a thing',
|
||||
'',
|
||||
'## Session', '', 'Stopped at: somewhere', 'Paused At: elsewhere', '',
|
||||
]);
|
||||
update('Status', 'Executing');
|
||||
|
||||
const fm = stateText().split('---')[1];
|
||||
assert.match(fm, /^last_updated:/m, 'precondition: the write must have synced frontmatter');
|
||||
|
||||
const emitted = fm.split('\n')
|
||||
.filter((l) => /^[a-z_]+:/.test(l))
|
||||
.map((l) => l.split(':')[0].trim());
|
||||
const mapped = new Set(Object.keys(stateTransitionMod.FRONTMATTER_BODY_SOURCE));
|
||||
|
||||
for (const key of emitted) {
|
||||
assert.ok(
|
||||
mapped.has(key) || NOT_BODY_DERIVED.has(key),
|
||||
`buildStateFrontmatter emits "${key}", which is neither mapped in FRONTMATTER_BODY_SOURCE nor declared non-body-derived — `
|
||||
+ 'if it is body-derived, `state update` cannot name its body source',
|
||||
);
|
||||
}
|
||||
// And the map may not carry a key the builder never emits.
|
||||
for (const key of mapped) {
|
||||
assert.ok(emitted.includes(key), `FRONTMATTER_BODY_SOURCE maps "${key}", which the builder did not emit — the map has drifted`);
|
||||
}
|
||||
});
|
||||
|
||||
test('property: every body label round-trips back to its frontmatter key', () => {
|
||||
const entries = Object.entries(stateTransitionMod.FRONTMATTER_BODY_SOURCE);
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 0, max: entries.length - 1 }), fc.boolean(), (i, upper) => {
|
||||
const [key, labels] = entries[i];
|
||||
for (const label of labels) {
|
||||
const probe = upper ? label.toUpperCase() : label.toLowerCase();
|
||||
assert.strictEqual(
|
||||
stateTransitionMod.frontmatterKeyForBodyField(probe),
|
||||
key,
|
||||
`"${probe}" must resolve back to "${key}"`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 25 },
|
||||
);
|
||||
});
|
||||
|
||||
test('inherited prototype members are not treated as fields', () => {
|
||||
// Both lookups are own-property only; a prototype member must not produce a
|
||||
// bogus "is a derived key" reason.
|
||||
for (const probe of ['toString', 'constructor', 'valueOf', '__proto__', 'hasOwnProperty']) {
|
||||
assert.strictEqual(stateTransitionMod.getFrontmatterBodySource(probe), null, `${probe} is not a frontmatter key`);
|
||||
assert.strictEqual(stateTransitionMod.frontmatterKeyForBodyField(probe), null, `${probe} is not a body field`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user