* refactor(#1771): ADR-1769 Phase 1 — STATE.md Transition Module substrate + beginPhase Lands the Phase 1 substrate per ADR-1769: - src/state-transition.cts (new Module): - Field-classification table (FieldClass enum + FIELD_CLASSIFICATION rows) - STATE_MD_SECTIONS constants block - Pure transitionCore(content, intent, deps) dispatch - beginPhase intent implementation (first-time + #3127 resume paths) - src/state.cts:cmdStateBeginPhase — collapses ~190 lines to a thin dispatch onto transitionCore via readModifyWriteStateMd. The lock, no-op write guard, and #1230 post-sync delta heuristic stay in the RMW seam; the body-mutation policy moves to transitionCore. - tests/state-transition.test.cjs (24 tests): - Substrate invariants (table enum, section constants) - Characterization: 6 first-time body field updates - Characterization: 5 #3127 idempotency-guard resume behaviors - Characterization: 3 Current Position section mutations - Characterization: Current focus body text line (#1104) - Property (RULESET.TESTS.property-based-testing): beginPhase status propagation + FIELD_CLASSIFICATION own-property contract - Resume Current Position mutation (preserves Plan/Phase/Status) No external behavior change. Full state.test.cjs regression (177 tests) plus bug-3127/#3242/#905/#948 pass. Property tests surfaced two pre-existing quirks (state-document.cjs greedy \s* on whitespace-only field values; Object.prototype method leakage on FIELD_CLASSIFICATION lookups for strings like 'toString') — documented in test comments; fix-out-of-scope for Phase 1. Closes #1771 * refactor(#1771): ADR-1769 Phase 1 codex review corrections Addresses 3 blocking findings from codex gpt-5.5/high review: 1. FIELD_CLASSIFICATION shape (state-transition.cts): - Was flat FieldClass enum (collapsed source + preservation) - Now two-column {source, preservation} rows per ADR-1769 §4 - Added missing fields verified via Memtrace against buildStateFrontmatter (state.cts:1633-1653): gsd_state_version, last_updated, last_activity_desc, progress.{total_phases, completed_phases, total_plans, completed_plans, percent} - Field 2-7 preservation dispatch can now consult the table 2. Prototype-pollution hardening (state-transition.cts): - Table is now Object.freeze(Object.assign(Object.create(null), {...})) - getFieldClassification() helper uses Object.hasOwn; returns null for inherited prototype methods (toString/valueOf/__proto__) - Old code: FIELD_CLASSIFICATION['toString'] returned the function 3. STATE_MD_SECTIONS aligned to canonical template: - Verified against gsd-core/templates/state.md via Memtrace - Was: 8 entries including non-template sections (## Session, ## Decisions, ## Operator Next Steps, ## Session Log, ## Roadmap Evolution) - Now: 6 canonical top-level sections (## Project Reference, ## Current Position, ## Performance Metrics, ## Accumulated Context, ## Deferred Items, ## Session Continuity) Also: beginPhase now consults getFieldClassification() per touched field (codex finding: 'table not consulted by transitionCore'). Unknown fields raise immediately — adding a field without a table row is caught at runtime. Repo-hygiene catches from gsd-test (not node --test, which missed these): - gsd-core/bin/lib/state-transition.cjs added to eslint.config.mjs ignore list (ADR-457 tsc-generated) - docs/INVENTORY-MANIFEST.json regenerated via node scripts/gen-inventory-manifest.cjs --write Property test for Object.prototype leakage tightened to verify getFieldClassification() returns null for toString/valueOf/__proto__. Ref #1771 * fix(#1771): cast Object.create(null) to satisfy @typescript-eslint/no-unsafe-assignment ESLint CI failed on src/state-transition.cts:72:14 — Object.create(null) returns `any`, which leaked through Object.assign to the typed `FIELD_CLASSIFICATION` declaration. Adding an explicit cast to `Record<string, FieldClassification>` eliminates the unsafe-assignment while preserving the null-prototype protection codex review recommended. gsd-test: 21888/21888 PASS. * fix(#1771): add 'see #1771' to allow-test-rule exemption per ADR-456 CI lint-allow-test-rule-refs failed: 'New allow-test-rule exemption without an issue ref — add `see #NNN` per ADR-456'. Updated comment on tests/state-transition.test.cjs to reference the Phase 1 issue. * fix(#1771): remove unnecessary allow-test-rule exemption The exemption was added speculatively. The test file does not use readFileSync + .includes()/.match()/.startsWith() on source content — it calls transitionCore() with string literals and verifies results via stateExtractField() and array .includes() on the updated[] array. No exemption needed.
340 lines
16 KiB
JavaScript
340 lines
16 KiB
JavaScript
"use strict";
|
||
/**
|
||
* STATE.md Transition Module — ADR-1769.
|
||
*
|
||
* Phase 1 substrate: field-classification table, section constants, the pure
|
||
* `transitionCore` dispatch, and the `beginPhase` intent (migrating
|
||
* `cmdStateBeginPhase` in state.cts onto this seam).
|
||
*
|
||
* Sibling/super-module of the STATE.md Document Module (state-document.cjs):
|
||
* consumes its `stateExtractField` / `stateReplaceField` primitives. Body
|
||
* section headings live as constants here (single writer after migration).
|
||
*
|
||
* Pure core + injected I/O (ADR-1769 §3): the exported `transitionCore` is a
|
||
* pure function `(content, intent, deps) → result`; adapters that own locks,
|
||
* file I/O, and the disk-scan wrap it.
|
||
*/
|
||
Object.defineProperty(exports, "__esModule", { value: true });
|
||
exports.STATE_MD_SECTIONS = exports.FIELD_CLASSIFICATION = void 0;
|
||
exports.getFieldClassification = getFieldClassification;
|
||
exports.transitionCore = transitionCore;
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
const frontmatter = require("./frontmatter.cjs");
|
||
const state_document_cjs_1 = require("./state-document.cjs");
|
||
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
const phaseIdMod = require("./phase-id.cjs");
|
||
const { extractFrontmatter, reconstructFrontmatter } = frontmatter;
|
||
const { escapeRegex } = phaseIdMod;
|
||
// Stop predicate for section-body slicing: a level-2+ heading ends the section.
|
||
const STOP_H2_PLUS = (lv) => lv >= 2;
|
||
/**
|
||
* Single source of truth for "which fields win when frontmatter and body
|
||
* disagree". Transitions declare which body fields they touch; the core
|
||
* consults the table to apply the preservation policy uniformly.
|
||
*
|
||
* Adding a new STATE.md field = one row here, not 9 transition edits.
|
||
*
|
||
* Field set verified against `buildStateFrontmatter` (state.cts:1474) — every
|
||
* frontmatter key emitted there has a row here.
|
||
*
|
||
* Frozen null-prototype object: prevents prototype-pollution lookups
|
||
* (`FIELD_CLASSIFICATION['toString']` returns undefined, not the inherited
|
||
* function). Use `getFieldClassification()` for lookups.
|
||
*/
|
||
exports.FIELD_CLASSIFICATION = Object.freeze(Object.assign(Object.create(null), {
|
||
// Schema
|
||
gsd_state_version: { source: 'free', preservation: 'derive' },
|
||
// Milestone (external — from ROADMAP.md)
|
||
milestone: { source: 'external', preservation: 'preserve-if-placeholder' },
|
||
milestone_name: { source: 'external', preservation: 'preserve-if-placeholder' },
|
||
// Phase / plan position (body-derived)
|
||
current_phase: { source: 'body', preservation: 'preserve-when-unchanged' },
|
||
current_phase_name: { source: 'curated', preservation: 'preserve-always' }, // #1743, #1695
|
||
current_plan: { source: 'body', preservation: 'preserve-when-unchanged' },
|
||
// Status / lifecycle (body-derived; #1230 delta heuristic applies)
|
||
status: { source: 'body', preservation: 'preserve-when-unchanged' },
|
||
stopped_at: { source: 'body', preservation: 'preserve-when-unchanged' },
|
||
paused_at: { source: 'body', preservation: 'preserve-when-unchanged' },
|
||
// Activity log
|
||
last_updated: { source: 'free', preservation: 'derive' }, // realClock.nowIso()
|
||
last_activity: { source: 'body', preservation: 'derive' }, // always refresh on transition
|
||
last_activity_desc: { source: 'body', preservation: 'preserve-when-unchanged' },
|
||
// Progress block (disk-derived, except the curated progress ratchet)
|
||
progress: { source: 'curated', preservation: 'preserve-always' }, // #3242, #1446
|
||
'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' },
|
||
}));
|
||
/**
|
||
* Own-property classification lookup. Returns `null` for unknown fields
|
||
* (including inherited prototype methods like `toString`/`valueOf`).
|
||
*/
|
||
function getFieldClassification(field) {
|
||
if (!Object.prototype.hasOwnProperty.call(exports.FIELD_CLASSIFICATION, field))
|
||
return null;
|
||
return exports.FIELD_CLASSIFICATION[field];
|
||
}
|
||
// ----------------------------------------------------------------------------
|
||
// Body section constants (ADR-1769 §6 — single writer after migration)
|
||
// ----------------------------------------------------------------------------
|
||
/**
|
||
* Top-level STATE.md section headings (H2). Aligned byte-for-byte with the
|
||
* canonical template at `gsd-core/templates/state.md`. Sub-headings (H3) like
|
||
* `### Decisions` / `### Pending Todos` / `### Blockers/Concerns` live under
|
||
* `## Accumulated Context` and are not mutated by any Phase 1–7 transition;
|
||
* they will be added here if a future transition needs them.
|
||
*
|
||
* Verified against `gsd-core/templates/state.md` (codex Phase 1 review).
|
||
*/
|
||
exports.STATE_MD_SECTIONS = {
|
||
projectReference: '## Project Reference',
|
||
currentPosition: '## Current Position',
|
||
performanceMetrics: '## Performance Metrics',
|
||
accumulatedContext: '## Accumulated Context',
|
||
deferredItems: '## Deferred Items',
|
||
sessionContinuity: '## Session Continuity',
|
||
};
|
||
// ----------------------------------------------------------------------------
|
||
// transitionCore — pure dispatch (ADR-1769 §3)
|
||
// ----------------------------------------------------------------------------
|
||
/**
|
||
* Pure transition core. `(content, intent, deps) → result`.
|
||
*
|
||
* Discriminated-union dispatch via plain `switch` (ADR-1769 §2.7 Kernighan's
|
||
* Law: debuggability over conciseness; the substrate sets the pattern).
|
||
*
|
||
* Phases 2–7 add cases for the remaining 9 intent kinds. A missing case is
|
||
* a compile-time error (the function would not return on that path).
|
||
*/
|
||
function transitionCore(content, intent, deps) {
|
||
switch (intent.kind) {
|
||
case 'beginPhase':
|
||
return beginPhaseCore(content, intent, deps);
|
||
}
|
||
}
|
||
// ----------------------------------------------------------------------------
|
||
// beginPhase — intent implementation (Phase 1)
|
||
// ----------------------------------------------------------------------------
|
||
/**
|
||
* Apply a `beginPhase` transition to STATE.md content.
|
||
*
|
||
* Phase 1 scope (this file): the Status field update only. Subsequent
|
||
* behaviors land via RED-GREEN cycles per the ADR-1769 migration plan:
|
||
* - Current Phase, Current Phase Name, Current Plan, Total Plans
|
||
* - Current Position section mutation
|
||
* - Idempotency guard (#3127)
|
||
* - Resume vs first-time branching
|
||
* - #1255 / #1257 format-detection parity
|
||
*
|
||
* Adapters that acquire the STATE.md lock and call this core live in
|
||
* state.cts and consume the existing `readModifyWriteStateMd` post-sync
|
||
* machinery (preserves the #1230 delta heuristic without re-implementing it).
|
||
*/
|
||
function beginPhaseCore(content, intent, deps) {
|
||
const updated = [];
|
||
// #1255: body-field replacements operate on body only (frontmatter stripped),
|
||
// not on the full content. The YAML `status:` key matches `^Status:\s*`
|
||
// before the body pipe-table row if full content is passed.
|
||
const existingFm = extractFrontmatter(content);
|
||
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
||
let body = stripFrontmatter(content);
|
||
const reassemble = (b) => hasFrontmatter
|
||
? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
|
||
: b;
|
||
const today = deps.clock.today();
|
||
// Consult the field-classification table for the frontmatter keys this
|
||
// transition touches (codex Phase 1 review: "table not consulted by
|
||
// transitionCore"). The table tracks FRONTMATTER keys (lowercase: `status`,
|
||
// `current_phase`, `last_activity`); body field names like `Status` /
|
||
// `Current Phase` are aliases and aren't enforced here — they're driven by
|
||
// the first-time/resume branching below, which encodes the same rules.
|
||
// Phase 2+ will dispatch preservation based on this lookup.
|
||
for (const fmKey of ['status', 'current_phase', 'current_plan', 'last_activity']) {
|
||
const cls = getFieldClassification(fmKey);
|
||
if (cls === null) {
|
||
throw new Error(`transitionCore beginPhase: frontmatter key ${JSON.stringify(fmKey)} is not in FIELD_CLASSIFICATION; ` +
|
||
`add a row per ADR-1769 §4 before touching it.`);
|
||
}
|
||
}
|
||
// Helper: try to replace a body field; push to `updated` on success.
|
||
// Body field names (Title Case: 'Status', 'Current Phase') are not in the
|
||
// table — they're body-side aliases of classified frontmatter keys.
|
||
const tryField = (name, value) => {
|
||
const replaced = (0, state_document_cjs_1.stateReplaceField)(body, name, value);
|
||
if (replaced !== null) {
|
||
body = replaced;
|
||
updated.push(name);
|
||
}
|
||
};
|
||
// #3127 idempotency guard: if Status already contains "Executing Phase N" for
|
||
// the current phase number, this is a resume (e.g. --wave N continue). Skip
|
||
// the first-time-only fields so mid-flight state (Current Plan, Total Plans,
|
||
// Current Phase Name, Last Activity Description) is preserved.
|
||
// Extract from body (not full content) so the YAML `status:` key cannot
|
||
// shadow the body Status field (#1255).
|
||
const currentStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status') || '';
|
||
const isAlreadyExecuting = new RegExp(`Executing Phase\\s+${escapeRegex(String(intent.phaseNumber))}\\b`, 'i').test(currentStatus);
|
||
// Status update (applies on both first-time and resume — Status is always refreshed).
|
||
tryField('Status', `Executing Phase ${intent.phaseNumber}`);
|
||
// Last Activity date — safe to refresh on resume (tracks when execute-phase ran).
|
||
tryField('Last Activity', today);
|
||
if (!isAlreadyExecuting) {
|
||
// First-time execution: set all progress fields.
|
||
tryField('Last Activity Description', `Phase ${intent.phaseNumber} execution started`);
|
||
tryField('Current Phase', String(intent.phaseNumber));
|
||
if (intent.phaseName) {
|
||
tryField('Current Phase Name', intent.phaseName);
|
||
}
|
||
tryField('Current Plan', '1');
|
||
if (intent.planCount) {
|
||
tryField('Total Plans in Phase', String(intent.planCount));
|
||
}
|
||
// **Current focus:** body text line (#1104).
|
||
const focusLabel = intent.phaseName
|
||
? `Phase ${intent.phaseNumber} — ${intent.phaseName}`
|
||
: `Phase ${intent.phaseNumber}`;
|
||
const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
|
||
if (focusPattern.test(body)) {
|
||
body = body.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
|
||
updated.push('Current focus');
|
||
}
|
||
// ## Current Position section mutation (#1104, #1365).
|
||
// ADR-1372 T6: tokenizeHeadings + offset splicing (replaceSection adoption
|
||
// deferred to a later phase). Mirrors state.cts:2261-2324 byte-for-behaviour.
|
||
body = mutateCurrentPositionFirstTime(body, intent, today, updated);
|
||
}
|
||
else {
|
||
// Resume path: only update Last activity timestamp in Current Position
|
||
// (do not touch Plan:, Phase:, Status:, stopped_at, progress.percent).
|
||
body = mutateCurrentPositionResume(body, intent, today, updated);
|
||
}
|
||
return { content: reassemble(body), updated };
|
||
}
|
||
/**
|
||
* Find the `## Current Position` section, return its `{start, end}` byte
|
||
* offsets in `body` (end is exclusive — first byte of the next section or
|
||
* body.length). Returns `null` when the section is absent.
|
||
*
|
||
* ADR-1372 T6: tokenizeHeadings-based locator (fence-aware).
|
||
*/
|
||
function locateCurrentPosition(body) {
|
||
const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body);
|
||
const idx = hs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
|
||
if (idx === -1)
|
||
return null;
|
||
const h = hs[idx];
|
||
const lines = body.split('\n');
|
||
const hl = lines[h.line - 1];
|
||
const start = h.offset + hl.length + 1;
|
||
let end = body.length;
|
||
for (let j = idx + 1; j < hs.length; j++) {
|
||
if (STOP_H2_PLUS(hs[j].level)) {
|
||
end = hs[j].offset - 1;
|
||
break;
|
||
}
|
||
}
|
||
return { start, end };
|
||
}
|
||
/**
|
||
* First-time ## Current Position mutation: update Phase / Plan / Status /
|
||
* Last activity lines. Mirrors state.cts:2261-2324 byte-for-behaviour
|
||
* (inline regex first, pipe-table fallback via stateReplaceField — #1257).
|
||
*/
|
||
function mutateCurrentPositionFirstTime(body, intent, today, updated) {
|
||
const span = locateCurrentPosition(body);
|
||
if (span === null)
|
||
return body;
|
||
let sectionBody = body.slice(span.start, span.end);
|
||
// Phase line — inline first, then pipe-table fallback (#1257).
|
||
const phaseLabel = `${intent.phaseNumber}${intent.phaseName ? ` (${intent.phaseName})` : ''} — EXECUTING`;
|
||
if (/^Phase:/m.test(sectionBody)) {
|
||
sectionBody = sectionBody.replace(/^Phase:.*$/m, `Phase: ${phaseLabel}`);
|
||
}
|
||
else {
|
||
const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Phase', phaseLabel);
|
||
if (replaced !== null)
|
||
sectionBody = replaced;
|
||
}
|
||
// Plan line.
|
||
const planValue = `1 of ${intent.planCount || '?'}`;
|
||
if (/^Plan:/m.test(sectionBody)) {
|
||
sectionBody = sectionBody.replace(/^Plan:.*$/m, `Plan: ${planValue}`);
|
||
}
|
||
else {
|
||
const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Plan', planValue);
|
||
if (replaced !== null)
|
||
sectionBody = replaced;
|
||
}
|
||
// Status line.
|
||
const statusValue = `Executing Phase ${intent.phaseNumber}`;
|
||
if (/^Status:/m.test(sectionBody)) {
|
||
sectionBody = sectionBody.replace(/^Status:.*$/m, `Status: ${statusValue}`);
|
||
}
|
||
else {
|
||
const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Status', statusValue);
|
||
if (replaced !== null)
|
||
sectionBody = replaced;
|
||
}
|
||
// Last activity line. The inline value carries date + narrative.
|
||
const activityValue = `${today} — Phase ${intent.phaseNumber} execution started`;
|
||
if (/^Last activity:/im.test(sectionBody)) {
|
||
sectionBody = sectionBody.replace(/^Last activity:.*$/im, `Last activity: ${activityValue}`);
|
||
}
|
||
else {
|
||
const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Last Activity', activityValue) ??
|
||
(0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Last activity', activityValue);
|
||
if (replaced !== null)
|
||
sectionBody = replaced;
|
||
}
|
||
updated.push('Current Position');
|
||
return body.slice(0, span.start) + sectionBody + body.slice(span.end);
|
||
}
|
||
/**
|
||
* Resume ## Current Position mutation: only update Last activity line
|
||
* (preserves Plan/Phase/Status — #3127). Mirrors state.cts:2329-2363
|
||
* byte-for-behaviour.
|
||
*/
|
||
function mutateCurrentPositionResume(body, intent, today, updated) {
|
||
const span = locateCurrentPosition(body);
|
||
if (span === null)
|
||
return body;
|
||
let sectionBody = body.slice(span.start, span.end);
|
||
const resumeActivity = `Last activity: ${today} — Phase ${intent.phaseNumber} execution resumed (wave continue)`;
|
||
if (/^Last activity:/im.test(sectionBody)) {
|
||
sectionBody = sectionBody.replace(/^Last activity:.*$/im, resumeActivity);
|
||
updated.push('Last activity (resume)');
|
||
}
|
||
else {
|
||
// Pipe-table format fallback (#1255).
|
||
const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Last Activity', resumeActivity) ??
|
||
(0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Last activity', resumeActivity);
|
||
if (replaced !== null) {
|
||
sectionBody = replaced;
|
||
updated.push('Last activity (resume)');
|
||
}
|
||
}
|
||
return body.slice(0, span.start) + sectionBody + body.slice(span.end);
|
||
}
|
||
/**
|
||
* Strip ALL frontmatter blocks from the start of `content`.
|
||
*
|
||
* TODO (ADR-1769 follow-up): move to `frontmatter.cjs` or `state-document.cjs`
|
||
* so it's a shared primitive. Inlined here in Phase 1 to avoid touching
|
||
* `state.cjs` (which is the migration target itself) and to keep the Phase 1
|
||
* diff contained. Body is byte-identical to `state.cts:1653 stripFrontmatter`
|
||
* (same CRLF + stacked-block handling).
|
||
*/
|
||
function stripFrontmatter(content) {
|
||
let result = content;
|
||
while (true) {
|
||
const stripped = result.replace(/^\s*---\r?\n[\s\S]*?\r?\n---\s*/, '');
|
||
if (stripped === result)
|
||
break;
|
||
result = stripped;
|
||
}
|
||
return result;
|
||
}
|