* test(#3873): failing-first locale parity, plus tripwires for what must not move Pins ADR-3473 §8.8 at the artifact a reader actually sees. The English STATE.md reference carries a Status lifecycle section that is missing from all four translations — the section documenting the status enum whose clobbering is #3853. The test derives the heading set rather than hard-coding the missing one, and names the locale and the heading when it fails. Two tripwires that must pass today and after. The field-drift guard still catches a re-derived fallback ladder: §8.8 instructs deleting that script, and that instruction rests on a wrong premise about what it guards, so the test stops a future reader from deleting it on the ADR's word. And last_activity's label resolution is pinned to what ships today, because it is declared in one of the two tables this phase consolidates and not the other — the consolidation must not silently pick a side. The locale test buckets under docs rather than state, which is what it tests; that bucket is allowlisted with justification rather than folded into an unrelated docs suite. It reads only markdown, so it carries no allow-test-rule marker — a marker there would suppress nothing and would grow the unverified pool against its ceiling. Refs #3873 * feat(#3873): one schema owns the STATE.md key set, three tables become projections ADR-3473 §8.8. The key set was declared in four places that had to agree by hand and already did not: FIELD_CLASSIFICATION, FRONTMATTER_BODY_SOURCE, FRONTMATTER_KEY_TO_BODY_LABEL and buildStateFrontmatter's emit behavior. One frozen null-prototype schema now declares each key's type, enum, cardinality, source, preservation, body source, body label, accepted parse shapes and whether it is emitted unconditionally; the three tables are derived from it at module load. The projections are byte-identical to the literals they replace, key order included, and the parity tests compare against verbatim copies of today's tables rather than re-deriving both sides from the schema — a parity test fed from one source proves nothing, which is how a consolidation ships a changed policy under a green test. last_activity was the live disagreement: present in one table, absent from the other. The schema declares what ships today rather than the tidier answer, and a test pins it. The schema is a leaf module and owns the four field-policy types, re-exported from state-transition so existing importers are untouched — the same split health-diagnostic-types made to break a CJS require cycle. Refs #3873 * feat(#3873): generate the schema-derived regions, parity-check the prose tables ADR-3473 §8.8's generator half. gen-state-md-docs.cjs owns marked regions in the shipped template and all five reference docs, follows gen-features.cjs's fail-closed contract, and is wired into regen:derived and lint:generated-sync. The Status lifecycle section was missing from all four translations — the section documenting the status enum behind #3853 — and is now generated into every locale. Field cardinality is a new generated table: pure schema data, no prose, so nothing to lose. The Field-reference and Status-values tables are parity-CHECKED rather than generated. Their Purpose, When-populated and Matched-text columns are genuinely hand-translated per locale, and §8.8 itself says prose stays hand-translated; generating them from an English registry would overwrite four locales' translations on every write. The row set is checked against the schema instead, so a key added to one and not the other fails, which is what field drift actually means. Building that check found last_activity_desc undocumented in all five tables. Three keys the docs describe are absent from the schema — active_phase, next_action, next_phases. They are grandfathered by name, not by wildcard, so a fourth fails: a declared gap with a forcing function rather than a silent one. Refs #3873 * fix(#3873): declare what the parsers do, and close the shape-parity gap Two declarations in the new schema described intended behavior rather than actual — the defect class this epic exists to end, committed inside the epic. Both were caught by executing the parsers instead of reading their docstrings. current_plan.acceptedShapes claimed ['N', 'N of M']. Standalone, the hybrid shape errors; the path that looks like support is parseInt truncating '2 of 5' to 2 and discarding the rest. Narrowed to ['N']. The parser is deliberately NOT fixed here: that is #3784 and PR #3791 is already doing it. When #3791 lands this row must widen, and the shape test will go red until it does — the schema and the parser cannot drift apart quietly, which is what §8.8's checked-not- generated rule is for. STATUS_LIFECYCLE_ENUM claimed to be the closed set status can hold. normalizeStateStatus passes unrecognized prose through unchanged, so it is not closed at runtime. The seven members are the canonical values it maps onto; the docstring now says that and the test asserts the real lenient contract. Closes the acceptance item that a test asserts the parsers accept exactly the declared shapes: the check is table-driven over every row carrying acceptedShapes, guarded against passing vacuously on an empty set, and fails loudly if a future row has no registered driver. Adds the unwired-label throw and the fast-check property that every projection agrees with its schema row. Refs #3873 * fix(#3873): keep the shipped template's frontmatter first, and make row 27 able to fail The remote matrix caught 12 failures with one cause. Making the template's frontmatter a generated region wrapped it in its own yaml fence ahead of the markdown fence, so extractFileTemplate and readShippedStateTemplateBody — which both match the single markdown block — found the heading first, not the frontmatter. That breaks the contract every new project's STATE.md is created from: bug #21 and epic #1969 B8 pin that the File Template block starts with frontmatter and carries gsd_state_version. The markers now sit inside the single markdown fence, so the fence opens before the frontmatter and the region still ends ahead of the heading. Same layout as before this phase, with markers embedded rather than a second fence. Row 27 existed to catch exactly this and did not, because it was writer-seeded: it asserted against the generator's own output shape, so it passed on the broken template. It now parses the fence the way production does and was verified to fail against the broken shape before being trusted against the fixed one. A test that would not have caught the bug it exists to prevent is worse than no test. The emitted-attribution failure was separate and the fragment was the wrong remedy: gsd-core/templates/state.md self-attributes under a verbatim-copy identity rule, so a diff touching it needs no acknowledgment. Fragment deleted rather than left explaining nothing. Refs #3873 * docs(#3873): how to change the STATE.md schema The phase gate was right and my docs artifact was wrong. I listed lint:generated-sync as the second enablement step, which is a verification command dressed as one, and then claimed a one-step sequence owed no how-to. The real sequence is build:lib then regen:derived, and the ordering is a trap: the generator reads the COMPILED schema, so regenerating before building regenerates against the previous schema and commits artifacts that look plausible while disagreeing with the code just written. A reference table cannot carry an ordering dependency; that is what the how-to test is for. The page covers adding, changing and removing a key, every reason code the check emits and what to do about each, what is generated versus hand-translated and why the two prose-bearing tables are parity-checked instead of generated, adding a language, and the three grandfathered keys. Indexed from docs/README.md. Refs #3873 * chore(#3873): backfill changeset PR number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
285
src/state-md-schema.cts
Normal file
285
src/state-md-schema.cts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* STATE.md Field Schema — the one declaration (ADR-3473 §8.8, issue #3873).
|
||||
*
|
||||
* Phase 3 substrate. Before this module, "which STATE.md keys exist and what
|
||||
* they carry" was declared in THREE hand-maintained places that were already
|
||||
* observed to disagree (see the `last_activity` docstring below):
|
||||
*
|
||||
* - `FIELD_CLASSIFICATION` (`src/state-transition.cts`) — source/preservation/
|
||||
* guard/mergeStrategy per frontmatter key (ADR-1769 §4 / ADR-3408).
|
||||
* - `FRONTMATTER_BODY_SOURCE` (`src/state-transition.cts`) — which BODY field
|
||||
* a frontmatter key derives from.
|
||||
* - `FRONTMATTER_KEY_TO_BODY_LABEL` (`src/state.cts`) — the Title-Case label
|
||||
* a report speaks a preserved field in (ADR-3408 §8.4/§8.5).
|
||||
*
|
||||
* This module is the single row-per-key declaration those three now PROJECT
|
||||
* from at load time (`state-transition.cts` / `state.cts`), rather than
|
||||
* hand-maintaining a fourth copy of the same knowledge. Every exported shape
|
||||
* of the three original tables is unchanged — same keys, same key ORDER, same
|
||||
* frozen/null-prototype-ness — so every existing consumer (the preservation
|
||||
* dispatch loop, `getFieldClassification`, `getPreserveWhenUnchangedFields`,
|
||||
* `bodyLabelFor`, and issue #3872's `declaredLeavesOf`) keeps working without
|
||||
* an edit. See `.gsd/phase/feat-3873-state-md-schema/40-design.md`.
|
||||
*
|
||||
* LEAF MODULE, DELIBERATELY. This file imports from neither `state-transition.cts`
|
||||
* nor `state.cts` — both of those import THIS module, and either importing
|
||||
* back would be the exact CJS require-cycle `src/health-diagnostic-types.cts`'s
|
||||
* own docstring describes breaking for the health-diagnostic rule tables
|
||||
* (`module.exports` read before it is assigned, so a destructured value comes
|
||||
* back `undefined`). `FieldSource` / `FieldPreservation` / `FieldGuard` /
|
||||
* `FieldMergeStrategy` therefore live HERE now and are re-exported (by the same
|
||||
* name, so no importer of `state-transition.cts` needs to change) from
|
||||
* `state-transition.cts`.
|
||||
*
|
||||
* ADR-457 build-at-publish: source in `src/state-md-schema.cts`, compiled to
|
||||
* `gsd-core/bin/lib/state-md-schema.cjs` (gitignored).
|
||||
*
|
||||
* Design: .gsd/phase/feat-3873-state-md-schema/40-design.md
|
||||
* Test matrix: .gsd/phase/feat-3873-state-md-schema/50-test-matrix.md
|
||||
*/
|
||||
|
||||
// ─── Closed vocabularies (moved from state-transition.cts, ADR-3408 Decision 1) ──
|
||||
//
|
||||
// Greenspun's Tenth Rule (ADR-3408 Decision 1): these are named members of a
|
||||
// CLOSED vocabulary, never an open predicate slot. Adding a member to any of
|
||||
// the four unions below is an amendment to ADR-3408, not a table edit —
|
||||
// unchanged from their pre-#3873 home in `state-transition.cts`.
|
||||
|
||||
export type FieldSource =
|
||||
| 'body' // value is derived from a body field (Phase:, Status:, etc.)
|
||||
| 'disk' // value is derived from a disk scan (.planning/phases/* counts)
|
||||
| 'external' // value is derived from an external file (ROADMAP.md milestone)
|
||||
| 'curated' // value is set by humans/tools; preserve unless explicitly overwritten
|
||||
| 'free'; // caller's word is law (no preservation)
|
||||
|
||||
export type FieldPreservation =
|
||||
| 'derive' // always re-derive from source
|
||||
| 'preserve-when-unchanged' // #1230 delta heuristic: keep existing if body source field unchanged
|
||||
| 'preserve-always' // never overwrite unless the caller explicitly names this field
|
||||
| 'preserve-if-placeholder'; // overwrite only when derived value is a known placeholder (#948)
|
||||
// ADR-3408 §8.6 amendment: 'clear' was deleted (no row used it, no executor
|
||||
// existed) rather than implemented — Speculative Generality, a policy
|
||||
// invented for a need that never arrived.
|
||||
|
||||
export type FieldGuard = 'non-sentinel-unknown';
|
||||
export type FieldMergeStrategy = 'progress-ratchet';
|
||||
|
||||
/**
|
||||
* The seven CANONICAL values `normalizeStateStatus` (`src/state-document.cts`)
|
||||
* maps recognized raw status prose TO — the function's default fallback plus
|
||||
* each branch's literal output, in the order the function tests them. This is
|
||||
* NOT the raw body prose vocabulary `CONTEXT.md`'s "STATE.md Status Lifecycle
|
||||
* (ADR-2207)" entry documents (`Ready to plan` → `All phases complete` →
|
||||
* `<version> milestone complete` → `Awaiting next milestone`, plus the
|
||||
* handler-authored strings in `KNOWN_TEMPLATE_DEFAULTS['Status']`) — that is
|
||||
* free-form prose `normalizeStateStatus` READS.
|
||||
*
|
||||
* CORRECTED (#3873 phase-3 test-matrix row 26 — verified by executing
|
||||
* `normalizeStateStatus`, not by reading this docstring's prior claim):
|
||||
* this is NOT a closed set the `status` frontmatter key is restricted to at
|
||||
* runtime. `normalizeStateStatus` is deliberately LENIENT: its fallback is
|
||||
* `normalizedStatus = status || 'unknown'`, and when none of its
|
||||
* substring-match branches recognize the raw input, that fallback — the
|
||||
* caller's raw, UNRECOGNIZED prose — is returned unchanged. A status value
|
||||
* outside this seven-member set is not rejected, coerced, or normalized; it
|
||||
* passes straight through into the frontmatter. `STATUS_LIFECYCLE_ENUM` is
|
||||
* therefore the set of values the normalizer maps recognized input ONTO, not
|
||||
* a runtime-enforced closed vocabulary for the field.
|
||||
*/
|
||||
export const STATUS_LIFECYCLE_ENUM = Object.freeze([
|
||||
'unknown',
|
||||
'paused',
|
||||
'executing',
|
||||
'planning',
|
||||
'discussing',
|
||||
'verifying',
|
||||
'completed',
|
||||
] as const);
|
||||
|
||||
// ─── The schema row shape ───────────────────────────────────────────────────
|
||||
|
||||
export type StateFieldSchema = {
|
||||
type: 'string' | 'number' | 'boolean' | 'object';
|
||||
/** Closed value set (currently only `status`'s ADR-2207 lifecycle). */
|
||||
enum?: readonly string[];
|
||||
cardinality: 'one' | 'optional' | 'many';
|
||||
source: FieldSource;
|
||||
preservation: FieldPreservation;
|
||||
/** Closed vocabulary (see `FieldGuard` above). Adding a member is an ADR-3408 amendment. */
|
||||
guard?: FieldGuard;
|
||||
/** Closed vocabulary (see `FieldMergeStrategy` above). Same rule. */
|
||||
mergeStrategy?: FieldMergeStrategy;
|
||||
/** Which BODY field(s) this frontmatter key derives from, in fallback order. */
|
||||
bodySource?: readonly string[];
|
||||
/** The Title-Case label a preservation report speaks this field in. */
|
||||
bodyLabel?: string;
|
||||
/**
|
||||
* The value SHAPES a hand-written parser accepts for this field's body
|
||||
* source — a DECLARED SET, never a predicate (Greenspun's Tenth Rule/
|
||||
* ADR-3408 Decision 1 applies here too: this is data a test checks a parser
|
||||
* against, not executable matching logic the schema itself runs).
|
||||
*/
|
||||
acceptedShapes?: readonly string[];
|
||||
/** Mirrors `buildStateFrontmatter`'s (`src/state.cts`) null-guards. */
|
||||
emitted: 'always' | 'when-present';
|
||||
};
|
||||
|
||||
// ─── The one declaration ────────────────────────────────────────────────────
|
||||
//
|
||||
// Row order below is `FIELD_CLASSIFICATION`'s (`src/state-transition.cts`,
|
||||
// pre-#3873) ORIGINAL literal order, verified by direct read and preserved
|
||||
// deliberately: the `FIELD_CLASSIFICATION` projection built from this table
|
||||
// (`state-transition.cts`) walks `Object.keys(STATE_FIELD_SCHEMA)` directly,
|
||||
// so this row order IS that projection's key order, and key order is
|
||||
// observable (the preservation dispatch loop iterates it). The two other
|
||||
// projections (`FRONTMATTER_BODY_SOURCE`, `FRONTMATTER_KEY_TO_BODY_LABEL`) do
|
||||
// NOT reuse this same order — their pre-#3873 literals were independently
|
||||
// hand-written and already disagreed with each other and with this order (see
|
||||
// each projection's own ordering constant in its home module) — so each
|
||||
// projection module declares its OWN explicit key-order list rather than
|
||||
// re-deriving order from this table's iteration, which would silently change
|
||||
// two of the three tables' observable order out from under every consumer.
|
||||
export const STATE_FIELD_SCHEMA: Readonly<Record<string, StateFieldSchema>> = Object.freeze(
|
||||
Object.assign(
|
||||
Object.create(null) as Record<string, StateFieldSchema>,
|
||||
{
|
||||
// Schema
|
||||
gsd_state_version: {
|
||||
type: 'string', cardinality: 'one', source: 'free', preservation: 'derive', emitted: 'always',
|
||||
} as StateFieldSchema,
|
||||
|
||||
// Milestone (external — from ROADMAP.md)
|
||||
milestone: {
|
||||
type: 'string', cardinality: 'optional', source: 'external', preservation: 'preserve-if-placeholder', emitted: 'when-present',
|
||||
} as StateFieldSchema,
|
||||
milestone_name: {
|
||||
type: 'string', cardinality: 'optional', source: 'external', preservation: 'preserve-if-placeholder', emitted: 'when-present',
|
||||
} as StateFieldSchema,
|
||||
|
||||
// Phase / plan position (body-derived)
|
||||
current_phase: {
|
||||
type: 'string', cardinality: 'optional', source: 'body', preservation: 'preserve-when-unchanged',
|
||||
bodySource: Object.freeze(['Current Phase']), bodyLabel: 'Current Phase', emitted: 'when-present',
|
||||
} as StateFieldSchema,
|
||||
current_phase_name: {
|
||||
type: 'string', cardinality: 'optional', source: 'curated', preservation: 'preserve-when-unchanged',
|
||||
bodySource: Object.freeze(['Current Phase Name']), bodyLabel: 'Current Phase Name', emitted: 'when-present',
|
||||
} as StateFieldSchema,
|
||||
current_plan: {
|
||||
type: 'string', cardinality: 'optional', source: 'body', preservation: 'preserve-when-unchanged',
|
||||
bodySource: Object.freeze(['Current Plan']), bodyLabel: 'Current Plan',
|
||||
// #3873 phase-3 test-matrix row 25 (verified by executing
|
||||
// `advancePlanCore`, `src/state-transition.cts:1306`, not by reading
|
||||
// its docstring): TODAY the `Current Plan` body field parses in
|
||||
// exactly ONE shape — a bare number `N`, paired with a separate
|
||||
// `Total Plans in Phase` field. The hybrid compound `N of M` written
|
||||
// directly into `Current Plan` (no `Total Plans in Phase` sibling)
|
||||
// does NOT parse: `legacyTotal` is absent, `planField` reads the
|
||||
// DIFFERENT `Plan` field (also absent), so the function falls to its
|
||||
// NaN/NaN error branch. Feeding `Current Plan: 2 of 5` WITH a
|
||||
// `Total Plans in Phase` sibling present does not change this — it
|
||||
// "succeeds" only because `parseInt("2 of 5", 10)` truncates to `2`
|
||||
// and the sibling supplies the total; the `of 5` half is silently
|
||||
// discarded, which is `parseInt` coincidence, not shape recognition.
|
||||
// `Plan: N of M` (a DIFFERENT field name) DOES parse the hybrid shape,
|
||||
// but `buildStateFrontmatter` never reads `Plan` into `current_plan`
|
||||
// (verified: it calls `stateExtractField(bodyContent, 'Current Plan')`
|
||||
// only), so that shape is out of scope for this row regardless.
|
||||
//
|
||||
// #3784 is the open issue for teaching `Current Plan` to read 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. Do NOT widen
|
||||
// this row speculatively — that would assert a shape the shipped
|
||||
// parser does not accept, which is the exact defect class §8.8
|
||||
// exists to make impossible. When #3791 merges, `acceptedShapes`
|
||||
// MUST widen to `['N', 'N of M']` — until then, the row 23/24/25
|
||||
// parser-shape tests (`tests/state-transition.test.cjs`) will go RED
|
||||
// the moment the parser changes underneath it. That failure is the
|
||||
// forcing function working as designed, not a broken test: it is
|
||||
// what stops the schema and the parser from drifting apart silently.
|
||||
acceptedShapes: Object.freeze(['N']),
|
||||
emitted: 'when-present',
|
||||
} as StateFieldSchema,
|
||||
|
||||
// Status / lifecycle (body-derived; #1230 delta heuristic applies)
|
||||
// guard: the 'unknown' sentinel is the ONLY true executor-side guard in
|
||||
// this table (stopped_at's `## Session` scoping is caller-side delta
|
||||
// extraction, not an executor condition) — ADR-3408 Decision 1.
|
||||
status: {
|
||||
type: 'string', enum: STATUS_LIFECYCLE_ENUM, cardinality: 'one', source: 'body', preservation: 'preserve-when-unchanged',
|
||||
guard: 'non-sentinel-unknown', bodySource: Object.freeze(['Status']), bodyLabel: 'Status', emitted: 'always',
|
||||
} as StateFieldSchema,
|
||||
stopped_at: {
|
||||
type: 'string', cardinality: 'optional', source: 'body', preservation: 'preserve-when-unchanged',
|
||||
bodySource: Object.freeze(['Stopped At', 'Stopped at']), bodyLabel: 'Stopped At', emitted: 'when-present',
|
||||
} as StateFieldSchema,
|
||||
paused_at: {
|
||||
type: 'string', cardinality: 'optional', source: 'body', preservation: 'preserve-when-unchanged',
|
||||
bodySource: Object.freeze(['Paused At']), bodyLabel: 'Paused At', emitted: 'when-present',
|
||||
} as StateFieldSchema,
|
||||
|
||||
// Activity log
|
||||
last_updated: {
|
||||
type: 'string', cardinality: 'one', source: 'free', preservation: 'derive', emitted: 'always',
|
||||
} as StateFieldSchema, // realClock.nowIso()
|
||||
// #3873: THE LIVE DISAGREEMENT. Pre-schema, `FRONTMATTER_BODY_SOURCE`
|
||||
// carried this key (`last_activity: ['Last Activity', 'Last activity']`)
|
||||
// while `FRONTMATTER_KEY_TO_BODY_LABEL` did NOT — same field, two
|
||||
// tables, two different answers to "does this key have a reportable
|
||||
// body label". Resolved by DECLARATION, not by picking whichever table
|
||||
// "looks right": `bodySource` is present below (this key IS derived from
|
||||
// a body field and `buildStateFrontmatter` — `src/state.cts` — reads it
|
||||
// via that exact two-case-variant fallback), and `bodyLabel` is
|
||||
// deliberately ABSENT, because that is what ships TODAY —
|
||||
// `last_activity`'s `preservation` is `'derive'`, never
|
||||
// `'preserve-when-unchanged'`, so it can never reach `bodyLabelFor`'s
|
||||
// (`src/state.cts`) `STATE_BODY_LABEL_UNWIRED_ROW` throw in the first
|
||||
// place; the absent label is inert, not a latent bug. Pinned by
|
||||
// `tests/state.test.cjs`'s pre-existing
|
||||
// `lastActivityLabelResolutionMatchesShippedBehavior`. Do NOT "tidy"
|
||||
// this by adding a label — that would be shipping a policy change
|
||||
// disguised as a consolidation, exactly the #3427 failure this epic is
|
||||
// named after.
|
||||
last_activity: {
|
||||
type: 'string', cardinality: 'optional', source: 'body', preservation: 'derive',
|
||||
bodySource: Object.freeze(['Last Activity', 'Last activity']), emitted: 'when-present',
|
||||
} as StateFieldSchema, // always refresh on transition
|
||||
last_activity_desc: {
|
||||
type: 'string', cardinality: 'optional', source: 'body', preservation: 'preserve-when-unchanged',
|
||||
bodySource: Object.freeze(['Last Activity Description']), bodyLabel: 'Last Activity Description', emitted: 'when-present',
|
||||
} as StateFieldSchema,
|
||||
|
||||
// Commit provenance (#2573) — ambient git read, recomputed on every write,
|
||||
// exactly like last_updated. Never preserved: a stale stamp would claim
|
||||
// STATE.md was written against a commit it wasn't.
|
||||
state_head: {
|
||||
type: 'string', cardinality: 'optional', source: 'free', preservation: 'derive', emitted: 'when-present',
|
||||
} as StateFieldSchema, // #2573
|
||||
|
||||
// Progress block (disk-derived, except the curated progress ratchet)
|
||||
// mergeStrategy: 'progress-ratchet' — completed_plans/completed_phases
|
||||
// only ever ratchet UP toward the derived value (#2969); everything
|
||||
// else in the merge is either always-derived (#2440) or always-curated.
|
||||
progress: {
|
||||
type: 'object', cardinality: 'optional', source: 'curated', preservation: 'preserve-always',
|
||||
mergeStrategy: 'progress-ratchet', emitted: 'when-present',
|
||||
} as StateFieldSchema, // #3242, #1446
|
||||
'progress.total_phases': {
|
||||
type: 'number', cardinality: 'optional', source: 'disk', preservation: 'derive', emitted: 'when-present',
|
||||
} as StateFieldSchema,
|
||||
'progress.completed_phases': {
|
||||
type: 'number', cardinality: 'optional', source: 'disk', preservation: 'derive', emitted: 'when-present',
|
||||
} as StateFieldSchema,
|
||||
'progress.total_plans': {
|
||||
type: 'number', cardinality: 'optional', source: 'disk', preservation: 'derive', emitted: 'when-present',
|
||||
} as StateFieldSchema,
|
||||
'progress.completed_plans': {
|
||||
type: 'number', cardinality: 'optional', source: 'disk', preservation: 'derive', emitted: 'when-present',
|
||||
} as StateFieldSchema,
|
||||
'progress.percent': {
|
||||
type: 'number', cardinality: 'optional', source: 'disk', preservation: 'derive', emitted: 'when-present',
|
||||
} as StateFieldSchema,
|
||||
} satisfies Record<string, StateFieldSchema>,
|
||||
),
|
||||
);
|
||||
@@ -22,6 +22,10 @@ import { tokenizeHeadings } from './markdown-sectionizer.cjs';
|
||||
import type { HeadingToken } from './markdown-sectionizer.cjs';
|
||||
import { deriveProgressFromRoadmap, clampPercent } from './phase-lifecycle.cjs';
|
||||
import { escapeRegex } from './pattern.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import stateMdSchemaMod = require('./state-md-schema.cjs');
|
||||
const { STATE_FIELD_SCHEMA } = stateMdSchemaMod;
|
||||
type StateFieldSchema = stateMdSchemaMod.StateFieldSchema;
|
||||
|
||||
const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter } = frontmatter;
|
||||
|
||||
@@ -40,33 +44,21 @@ const STOP_H2_PLUS = (lv: number): boolean => lv >= 2;
|
||||
// the collapsed-enum shape as a substrate defect that wouldn't survive
|
||||
// Phases 2–7.
|
||||
|
||||
export type FieldSource =
|
||||
| 'body' // value is derived from a body field (Phase:, Status:, etc.)
|
||||
| 'disk' // value is derived from a disk scan (.planning/phases/* counts)
|
||||
| 'external' // value is derived from an external file (ROADMAP.md milestone)
|
||||
| 'curated' // value is set by humans/tools; preserve unless explicitly overwritten
|
||||
| 'free'; // caller's word is law (no preservation)
|
||||
|
||||
export type FieldPreservation =
|
||||
| 'derive' // always re-derive from source
|
||||
| 'preserve-when-unchanged' // #1230 delta heuristic: keep existing if body source field unchanged
|
||||
| 'preserve-always' // never overwrite unless the caller explicitly names this field
|
||||
| 'preserve-if-placeholder'; // overwrite only when derived value is a known placeholder (#948)
|
||||
// ADR-3408 §8.6 amendment: 'clear' was deleted (no row used it, no executor
|
||||
// existed) rather than implemented — Speculative Generality, a policy
|
||||
// invented for a need that never arrived.
|
||||
|
||||
/**
|
||||
* ADR-3408 Decision 1 (Greenspun's Tenth Rule): guards and merge strategies
|
||||
* are named members of a CLOSED vocabulary, never an open predicate slot. The
|
||||
* table has already accreted five times (`preserve-always` #1743/#1695,
|
||||
* `preserve-if-placeholder` #948/#2135, `state_head` #2573, `deriveProgressKeys`
|
||||
* #2440, `bodyDeltas` #3258) — an open per-row predicate is what would turn
|
||||
* this table into an interpreter. Adding a member to either union below is an
|
||||
* amendment to ADR-3408, not a table edit.
|
||||
* #3873 (ADR-3473 §8.8): the four closed vocabularies below moved to
|
||||
* `src/state-md-schema.cts` — the leaf module `FIELD_CLASSIFICATION`'s
|
||||
* projection is now derived from — and are re-exported here BY THE SAME NAME
|
||||
* so no existing importer of this module needs to change (`state.cts`
|
||||
* consumes them via `stateTransitionMod.FieldSource` etc., the namespace
|
||||
* access pattern this module's plain `export type` already supported before
|
||||
* this move). See `state-md-schema.cts` for the full ADR-3408 Decision 1
|
||||
* ("Greenspun's Tenth Rule" — closed vocabulary, never an open predicate slot)
|
||||
* docstring these four used to carry directly.
|
||||
*/
|
||||
export type FieldGuard = 'non-sentinel-unknown';
|
||||
export type FieldMergeStrategy = 'progress-ratchet';
|
||||
export type FieldSource = stateMdSchemaMod.FieldSource;
|
||||
export type FieldPreservation = stateMdSchemaMod.FieldPreservation;
|
||||
export type FieldGuard = stateMdSchemaMod.FieldGuard;
|
||||
export type FieldMergeStrategy = stateMdSchemaMod.FieldMergeStrategy;
|
||||
|
||||
export type FieldClassification = {
|
||||
source: FieldSource;
|
||||
@@ -91,55 +83,30 @@ export type FieldClassification = {
|
||||
* (`FIELD_CLASSIFICATION['toString']` returns undefined, not the inherited
|
||||
* function). Use `getFieldClassification()` for lookups.
|
||||
*/
|
||||
/**
|
||||
* #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
|
||||
* (`src/state-md-schema.cts`) rather than hand-maintained here. Byte-identical
|
||||
* to the pre-#3873 literal table — same 19 keys, same key ORDER (walks
|
||||
* `Object.keys(STATE_FIELD_SCHEMA)` directly; see that module's row-order
|
||||
* comment for why this is the one projection allowed to do that), same
|
||||
* per-row shape (`{source, preservation, guard?, mergeStrategy?}`, in that
|
||||
* key order, `guard`/`mergeStrategy` present only when the schema row carries
|
||||
* them — never as an `undefined` own-property), same frozen null-prototype
|
||||
* container. Pinned by `tests/state-transition.test.cjs`'s
|
||||
* `fieldClassificationProjectionMatchesTodaysTable`, whose comparand is
|
||||
* today's literal copied VERBATIM into the test (never re-derived from this
|
||||
* schema — see that test's own docstring on why a self-referential parity
|
||||
* test proves nothing).
|
||||
*/
|
||||
export const FIELD_CLASSIFICATION: Readonly<Record<string, FieldClassification>> = Object.freeze(
|
||||
Object.assign(
|
||||
Object.create(null) as Record<string, FieldClassification>,
|
||||
{
|
||||
// Schema
|
||||
gsd_state_version: { source: 'free', preservation: 'derive' } as FieldClassification,
|
||||
|
||||
// Milestone (external — from ROADMAP.md)
|
||||
milestone: { source: 'external', preservation: 'preserve-if-placeholder' } as FieldClassification,
|
||||
milestone_name: { source: 'external', preservation: 'preserve-if-placeholder' } as FieldClassification,
|
||||
|
||||
// Phase / plan position (body-derived)
|
||||
current_phase: { source: 'body', preservation: 'preserve-when-unchanged' } as FieldClassification,
|
||||
// #1743, #1695. #3468: row corrected to match its long-standing behavior
|
||||
// — was declared preserve-always, has always been delta-gated (only
|
||||
// restores when the body `Phase:` source is unchanged this write).
|
||||
current_phase_name: { source: 'curated', preservation: 'preserve-when-unchanged' } as FieldClassification,
|
||||
current_plan: { source: 'body', preservation: 'preserve-when-unchanged' } as FieldClassification,
|
||||
|
||||
// Status / lifecycle (body-derived; #1230 delta heuristic applies)
|
||||
// guard: the 'unknown' sentinel is the ONLY true executor-side guard in
|
||||
// this table (stopped_at's `## Session` scoping is caller-side delta
|
||||
// extraction, not an executor condition) — ADR-3408 Decision 1.
|
||||
status: { source: 'body', preservation: 'preserve-when-unchanged', guard: 'non-sentinel-unknown' } as FieldClassification,
|
||||
stopped_at: { source: 'body', preservation: 'preserve-when-unchanged' } as FieldClassification,
|
||||
paused_at: { source: 'body', preservation: 'preserve-when-unchanged' } as FieldClassification,
|
||||
|
||||
// Activity log
|
||||
last_updated: { source: 'free', preservation: 'derive' } as FieldClassification, // realClock.nowIso()
|
||||
last_activity: { source: 'body', preservation: 'derive' } as FieldClassification, // always refresh on transition
|
||||
last_activity_desc: { source: 'body', preservation: 'preserve-when-unchanged' } as FieldClassification,
|
||||
|
||||
// Commit provenance (#2573) — ambient git read, recomputed on every write,
|
||||
// exactly like last_updated. Never preserved: a stale stamp would claim
|
||||
// STATE.md was written against a commit it wasn't.
|
||||
state_head: { source: 'free', preservation: 'derive' } as FieldClassification, // #2573
|
||||
|
||||
// Progress block (disk-derived, except the curated progress ratchet)
|
||||
// mergeStrategy: 'progress-ratchet' — completed_plans/completed_phases
|
||||
// only ever ratchet UP toward the derived value (#2969); everything
|
||||
// else in the merge is either always-derived (#2440) or always-curated.
|
||||
progress: { source: 'curated', preservation: 'preserve-always', mergeStrategy: 'progress-ratchet' } as FieldClassification, // #3242, #1446
|
||||
'progress.total_phases': { source: 'disk', preservation: 'derive' } as FieldClassification,
|
||||
'progress.completed_phases': { source: 'disk', preservation: 'derive' } as FieldClassification,
|
||||
'progress.total_plans': { source: 'disk', preservation: 'derive' } as FieldClassification,
|
||||
'progress.completed_plans': { source: 'disk', preservation: 'derive' } as FieldClassification,
|
||||
'progress.percent': { source: 'disk', preservation: 'derive' } as FieldClassification,
|
||||
} satisfies Record<string, FieldClassification>,
|
||||
),
|
||||
Object.keys(STATE_FIELD_SCHEMA).reduce((acc, key) => {
|
||||
const row: StateFieldSchema = STATE_FIELD_SCHEMA[key];
|
||||
const projected: FieldClassification = { source: row.source, preservation: row.preservation };
|
||||
if (row.guard !== undefined) projected.guard = row.guard;
|
||||
if (row.mergeStrategy !== undefined) projected.mergeStrategy = row.mergeStrategy;
|
||||
acc[key] = projected;
|
||||
return acc;
|
||||
}, Object.create(null) as Record<string, FieldClassification>),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -163,19 +130,36 @@ export const FIELD_CLASSIFICATION: Readonly<Record<string, FieldClassification>>
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
|
||||
* (`src/state-md-schema.cts`)'s `bodySource` field, in this EXPLICIT key
|
||||
* order. This order is NOT `STATE_FIELD_SCHEMA`'s own row order filtered down
|
||||
* to the body-sourced keys — the pre-#3873 literal already put `status`
|
||||
* before `stopped_at`/`paused_at` here while `FRONTMATTER_KEY_TO_BODY_LABEL`
|
||||
* (`src/state.cts`) put it AFTER them, i.e. the two pre-existing tables
|
||||
* disagreed with each other's order too, and this projection must reproduce
|
||||
* ITS table's order specifically. Byte-identical to the pre-#3873 literal —
|
||||
* same 8 keys, same order, same frozen null-prototype container with frozen
|
||||
* per-key arrays. Pinned by `tests/state-transition.test.cjs`'s
|
||||
* `bodySourceProjectionMatchesTodaysTable`.
|
||||
*/
|
||||
const FRONTMATTER_BODY_SOURCE_KEY_ORDER = Object.freeze([
|
||||
'current_phase',
|
||||
'current_phase_name',
|
||||
'current_plan',
|
||||
'status',
|
||||
'stopped_at',
|
||||
'paused_at',
|
||||
'last_activity',
|
||||
'last_activity_desc',
|
||||
] as const);
|
||||
|
||||
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[]>),
|
||||
FRONTMATTER_BODY_SOURCE_KEY_ORDER.reduce((acc, key) => {
|
||||
const row: StateFieldSchema = STATE_FIELD_SCHEMA[key];
|
||||
acc[key] = Object.freeze([...(row.bodySource ?? [])]);
|
||||
return acc;
|
||||
}, Object.create(null) as Record<string, readonly string[]>),
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,10 @@ import phaseLocatorMod = require('./phase-locator.cjs');
|
||||
const { listMilestonePhaseDirs } = phaseLocatorMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import stateTransitionMod = require('./state-transition.cjs');
|
||||
// #3873 (ADR-3473 §8.8): FRONTMATTER_KEY_TO_BODY_LABEL below is now a
|
||||
// projection of this leaf schema rather than a hand-maintained literal.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import stateMdSchemaMod = require('./state-md-schema.cjs');
|
||||
|
||||
// #2573 D5: used to pin `git rev-parse` to the project's own repo. Imports only
|
||||
// node builtins, so it introduces no cycle on this path.
|
||||
@@ -3728,15 +3732,38 @@ function readModifyWriteStateMd(statePath: string, transformFn: (content: string
|
||||
* exactly that closed, tested set (`tests/state.test.cjs` A2f pins
|
||||
* `divergedFields` reporting bare `'progress'`).
|
||||
*/
|
||||
const FRONTMATTER_KEY_TO_BODY_LABEL: Readonly<Record<string, string>> = Object.freeze({
|
||||
current_phase: 'Current Phase',
|
||||
current_phase_name: 'Current Phase Name',
|
||||
current_plan: 'Current Plan',
|
||||
stopped_at: 'Stopped At',
|
||||
paused_at: 'Paused At',
|
||||
status: 'Status',
|
||||
last_activity_desc: 'Last Activity Description',
|
||||
});
|
||||
/**
|
||||
* #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
|
||||
* (`src/state-md-schema.cts`)'s `bodyLabel` field, in this EXPLICIT key
|
||||
* order — the pre-#3873 literal's own order, which puts `status` AFTER
|
||||
* `stopped_at`/`paused_at` (the opposite of `FRONTMATTER_BODY_SOURCE`'s order
|
||||
* in `state-transition.cts`; the two pre-existing tables disagreed with each
|
||||
* other's order too, so each projection reproduces its OWN table's order
|
||||
* rather than a shared derivation). Byte-identical to the pre-#3873 literal:
|
||||
* same 7 keys, same order, same frozen (NOT null-prototype — this table was
|
||||
* a plain `Object.freeze({...})` literal before #3873 and stays one) shape.
|
||||
* `last_activity` is deliberately excluded — see `STATE_FIELD_SCHEMA`'s
|
||||
* `last_activity` row docstring for the resolved disagreement. Pinned by
|
||||
* `tests/state.test.cjs`'s `bodyLabelProjectionMatchesTodaysTable` and
|
||||
* `lastActivityLabelResolutionMatchesShippedBehavior`.
|
||||
*/
|
||||
const FRONTMATTER_KEY_TO_BODY_LABEL_KEY_ORDER = Object.freeze([
|
||||
'current_phase',
|
||||
'current_phase_name',
|
||||
'current_plan',
|
||||
'stopped_at',
|
||||
'paused_at',
|
||||
'status',
|
||||
'last_activity_desc',
|
||||
] as const);
|
||||
|
||||
const FRONTMATTER_KEY_TO_BODY_LABEL: Readonly<Record<string, string>> = Object.freeze(
|
||||
FRONTMATTER_KEY_TO_BODY_LABEL_KEY_ORDER.reduce((acc, key) => {
|
||||
const row = stateMdSchemaMod.STATE_FIELD_SCHEMA[key];
|
||||
if (row.bodyLabel !== undefined) acc[key] = row.bodyLabel;
|
||||
return acc;
|
||||
}, {} as Record<string, string>),
|
||||
);
|
||||
|
||||
/**
|
||||
* ADR-3408 §8.4 (D4) / #3471 review: label lookup for a `divergedFields`
|
||||
@@ -5842,6 +5869,12 @@ export = {
|
||||
_resolveFrontmatterPath: resolveFrontmatterPath,
|
||||
_stateFieldValuesDiffer: stateFieldValuesDiffer,
|
||||
_STATE_UPDATED_PROVENANCE_EXCLUSION: STATE_UPDATED_PROVENANCE_EXCLUSION,
|
||||
// Test seam (#3873 phase-3 test matrix row 9): `bodyLabelFor` itself is not
|
||||
// otherwise reachable from outside this module. Exposed so a test can drive
|
||||
// the real STATE_BODY_LABEL_UNWIRED_ROW throw directly, rather than only
|
||||
// pinning the table it reads (`_FRONTMATTER_KEY_TO_BODY_LABEL`) against
|
||||
// itself.
|
||||
_bodyLabelFor: bodyLabelFor,
|
||||
// Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
|
||||
// steal decision is exercised without real pids. Mirrors capability-lock.cts.
|
||||
_setLockProbes(probes: Partial<{ isPidAlive: (pid: number) => boolean }>): void {
|
||||
|
||||
Reference in New Issue
Block a user