chore(#2143): fail-loud Result + per-surface write-set contract — Phase 3 (#2251)

* chore(#2143): fail-loud Result + per-surface write-set contract — Phase 3

Phase 3 of epic #2143 (ADR-2143 §5/§6). The three target bugs (#2140, #2112,
#2118) were already fixed tactically on next; this introduces the reusable
structural contracts and rewires the primary #2140 site onto them.

- src/write-set.cts (new): the parse `Result<T> = {ok,value|reason}` (§5) and the
  per-surface write-set (`WriteOutcome {surface, applied, requirement?}`,
  `WriteSet`, `writeSetComplete`) (§6). markdown-table.cts now imports + re-exports
  `Result` from here (single source; distinct from command-routing-hub's Result).
- requirements mark-complete (src/milestone.cts): returns a PER-REQUIREMENT,
  per-surface write-set; `write_set_complete` is true only if every surface of
  every requirement applied — structurally forbidding the #2140 OR-into-one-flag
  masking, including across a multi-ID batch (adversarial-review regression).
  Pre-existing output fields unchanged (behaviour-preserving; #2140 already fixed).
- deriveProgressFromRoadmap (src/phase-lifecycle.cts): removed the vestigial
  null-swallowing try/catch (findTableWithColumns never throws) — ADR §5 no-swallow;
  RoadmapProgress return contract unchanged.
- commit --files (#2112) and milestone complete --dry-run (#2118) left as-is
  (single-surface commit / pre-mutation preview — not genuine multi-surface writes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#2244): backfill changeset PR number (#2251)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-13 20:39:58 -04:00
committed by GitHub
parent efd04716da
commit 2cbf186420
12 changed files with 381 additions and 43 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 2251
---
**`requirements mark-complete` reports a per-surface write-set** — the command now returns a per-requirement `write_set` (checkbox + traceability surfaces) and a `write_set_complete` that is true only when every surface of every requirement applied, so a partial (checkbox-only) reconcile can no longer masquerade as full success even inside a multi-ID batch. Introduces the reusable ADR-2143 §5/§6 `Result` / `WriteSet` contract. (#2251)

1
.gitignore vendored
View File

@@ -92,6 +92,7 @@ build/
/gsd-core/bin/lib/capability-lock.cjs
/gsd-core/bin/lib/markdown-sectionizer.cjs
/gsd-core/bin/lib/markdown-table.cjs
/gsd-core/bin/lib/write-set.cjs
/gsd-core/bin/lib/resolution.cjs
/gsd-core/bin/lib/research-store.cjs
/gsd-core/bin/lib/research-provider.cjs

View File

@@ -143,7 +143,10 @@ Module owning the tool's CLI I/O primitives: `output()` result emission (with la
Canonical markdown-structure parsing seam (`gsd-core/bin/lib/markdown-sectionizer.cjs`, generated from `src/markdown-sectionizer.cts`). Pure functions, Node built-ins only. Exports: `stripFencedCode(content) → { text, unterminatedFence }` (CommonMark-correct state machine, CRLF-safe, signals unterminated fences); `tokenizeHeadings(content) → HeadingToken[]` (ATX headings outside fenced blocks, `{ level, text, line, offset }`); `collectSections(content, stopPredicate) → Section[]` (line-by-line section collection driven by a heading predicate); `collectSection(content, headingPredicate, { levelBounded, stripFences }) → Section | null` (single named section with level-bounded stop); `iterateBullets(sectionText) → BulletItem[]` (dash/checkbox/numbered markers with indented continuation); `extractTaggedBlocks(content, tagName) → string[]` (inner text of every `<tagName>…</tagName>` block in document order, tagName regex-escaped, caller decides fence-stripping — generalises `decisions.cts`'s bespoke extractor for T1); `replaceSection(content, section, newBody) → string` (pure character-offset splice using `Section.bodyStart`/`bodyEnd` for read-modify-write callers — eliminates T6 `state.cts`'s 7× inline `content.replace` pattern); `withSection(content, target, edit) → string` (resolve the section whose heading matches `target` — exact heading text or a `HeadingToken` predicate — and run `edit(body)` against ONLY that section's body before splicing the result back; bounded no-op when no heading matches or `edit` returns the same/non-string body; ADR-2143 §4 structurally retires the #2130/#2067/#2080 boundary-crossing class by confining any regex the caller runs to the matched section). `Section` carries `bodyStart`/`bodyEnd` offsets for `replaceSection`. ADR-1372 (epic #1372) establishes this seam and a tiered migration plan (T0–T7) to retire the 8+ ad-hoc markdown parsers and ~20 inline section-collects across `src/*.cts`. New `src/*.cts` modules must import this seam instead of hand-rolling fence strippers or heading-regex section walks (enforced by the `no-adhoc-markdown-parsing` ESLint rule landing in tier T7).
### Markdown Table Model
Canonical GFM table parsing + schema registry seam (`gsd-core/bin/lib/markdown-table.cjs`, generated from `src/markdown-table.cts`; ADR-2143, epic #2143). Pure functions, Node built-ins only, string-in/value-out, no I/O. Exports: `parseMarkdownTable(sectionText) → Result<MarkdownTable>` (parses the first GFM pipe table found; typed `{ok:false,reason}` parse errors for no-table, missing/misaligned delimiter row, and ragged data rows — never silently drops or coerces a malformed row); `MarkdownTable` (`{columns: string[], rows: Record<string,string>[]}`, rows addressed by column name, not position); `Result<T>` (`{ok:true,value}\|{ok:false,reason}` — deliberately distinct from command-routing-hub's dispatch `Result` `{ok,data\|kind}`; the two never mix); `TABLE_SCHEMAS` (`Record<string, CanonicalTableVariant[]>` — the canonical column-header variants for every GFM table GSD parses or generates: `RoadmapProgress` flat/milestone-grouped, `RequirementsTraceability`, `QuickTasks` no-status/with-status, `Security` trust-boundaries/threat-register/accepted-risks/audit-trail); `matchTableSchema(columns) → {id,label}\|null` (resolves a parsed header back to its canonical schema by exact column-name/order match). This registry is the single source of truth for ROADMAP/STATE/SECURITY canonical tables — a parity test (`tests/markdown-table.test.cjs`) asserts every variant's header appears verbatim in the template/workflow file that generates it, so the registry and templates can never silently drift (ADR-2143 §3 Generative-Fix-Divergence guard). `phase-lifecycle.cts`'s `deriveProgressFromRoadmap` is the first consumer: it locates the Progress section via the Markdown Sectionizer's `collectSection` and reads cells by column NAME through this seam, fixing #2137 (the prior position-anchored regex assumed `Status` was always the 3rd cell, which broke for the 5-column milestone-grouped `Milestone` variant).
Canonical GFM table parsing + schema registry seam (`gsd-core/bin/lib/markdown-table.cjs`, generated from `src/markdown-table.cts`; ADR-2143, epic #2143). Pure functions, Node built-ins only, string-in/value-out, no I/O. Exports: `parseMarkdownTable(sectionText) → Result<MarkdownTable>` (parses the first GFM pipe table found; typed `{ok:false,reason}` parse errors for no-table, missing/misaligned delimiter row, and ragged data rows — never silently drops or coerces a malformed row); `MarkdownTable` (`{columns: string[], rows: Record<string,string>[]}`, rows addressed by column name, not position); `Result<T>` (`{ok:true,value}\|{ok:false,reason}` — re-exported from the Write-Set Module, the ADR-2143 §5 single source of truth for this shape, so existing importers of `Result` from `markdown-table.cjs` are unaffected; deliberately distinct from command-routing-hub's dispatch `Result` `{ok,data\|kind}`; the two never mix); `TABLE_SCHEMAS` (`Record<string, CanonicalTableVariant[]>` — the canonical column-header variants for every GFM table GSD parses or generates: `RoadmapProgress` flat/milestone-grouped, `RequirementsTraceability`, `QuickTasks` no-status/with-status, `Security` trust-boundaries/threat-register/accepted-risks/audit-trail); `matchTableSchema(columns) → {id,label}\|null` (resolves a parsed header back to its canonical schema by exact column-name/order match). This registry is the single source of truth for ROADMAP/STATE/SECURITY canonical tables — a parity test (`tests/markdown-table.test.cjs`) asserts every variant's header appears verbatim in the template/workflow file that generates it, so the registry and templates can never silently drift (ADR-2143 §3 Generative-Fix-Divergence guard). `phase-lifecycle.cts`'s `deriveProgressFromRoadmap` is the first consumer: it locates the Progress section via the Markdown Sectionizer's `collectSection` and reads cells by column NAME through this seam, fixing #2137 (the prior position-anchored regex assumed `Status` was always the 3rd cell, which broke for the 5-column milestone-grouped `Milestone` variant).
### Write-Set Module
Shared fail-loud `Result<T>` and per-surface write-set contracts (`gsd-core/bin/lib/write-set.cjs`, generated from `src/write-set.cts`; ADR-2143 §5/§6, epic #2143). Pure, Node built-ins only, no I/O. Exports: `Result<T>` (`{ok:true,value}\|{ok:false,reason}` — ADR-2143 §5 fail-loud parse shape, never a bare `null` a caller can mistake for "empty but fine"; the single source of truth `markdown-table.cjs` re-exports so its existing importers are unaffected; deliberately distinct from command-routing-hub's dispatch `Result` `{ok,data\|kind}`); `WriteOutcome` (`{surface: string, applied: boolean}` — one surface's outcome within a multi-surface write); `WriteSet` (`WriteOutcome[]`); `writeSetComplete(ws) → boolean` (true only when the set is non-empty AND every surface applied — ADR-2143 §6's "no OR-into-one-flag" rule: a command that mutates more than one surface must not collapse independent surface outcomes into a single boolean, the anti-pattern that let a checkbox-only partial write (#2140) report full success). `milestone.cts`'s `requirements mark-complete` handler is the first consumer: it reports a `write_set` (`checkbox`/`traceability` surfaces) and `write_set_complete` alongside its existing `updated`/`marked_complete`/`already_complete`/`not_found`/`table_unmatched` fields, which remain computed exactly as before — the write-set is additive, structured ADR-2143 documentation of the same per-surface facts #2140's tactical fix already exposed via `table_unmatched`.
### Roadmap Parser Module
Module owning ROADMAP.md parsing: shipped-milestone slicing, current-milestone extraction, milestone/phase lookups, and milestone-phase filtering (`stripShippedMilestones`, `extractCurrentMilestone`, `replaceInCurrentMilestone`, `getRoadmapPhaseInternal`, `getMilestoneInfo`, `getMilestonePhaseFilter`, `withPhaseSection`). `withPhaseSection(content, phaseId, edit)` resolves a phase's `### Phase N` detail-section heading via the #2121 phase-id source (`phaseMarkdownRegexSource`) and delegates to the markdown-sectionizer seam's `withSection`, so a per-phase ROADMAP edit is bounded to that phase's own section (ADR-2143 §4). Depends only on leaf modules (`phase-id`, `planning-workspace`, `shell-command-projection`, `markdown-sectionizer`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2b (#870), resolving the ROADMAP.md parse/write straddle so the Roadmap module (`roadmap.cjs`, which owns ROADMAP.md mutation) imports parsing directly instead of through Core; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/roadmap-parser.cjs` (generated from `src/roadmap-parser.cts`).

View File

@@ -447,7 +447,8 @@
"workstream-name-policy.cjs",
"workstream.cjs",
"worktree-base-ref.cjs",
"worktree-safety.cjs"
"worktree-safety.cjs",
"write-set.cjs"
],
"hooks": [
"gsd-check-update-worker.js",

View File

@@ -533,6 +533,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `workstream.cjs` | Workstream CRUD, migration, session-scoped active pointer |
| `worktree-base-ref.cjs` | Worktree base-ref drift detection and degrade decision (`evaluateWorktreeBaseDegrade`) plus no-clobber `worktree.baseRef` settings management for the `base-check`/`set-baseref` subcommands (#683) |
| `worktree-safety.cjs` | Worktree-root resolution and non-destructive prune policy decisions; owns W017 health-check logic |
| `write-set.cjs` | Shared fail-loud `Result<T>` (`{ok:true,value}\|{ok:false,reason}`) and per-surface write-set contracts (ADR-2143, epic #2143) — `WriteOutcome` (`{surface,applied}`), `WriteSet` (`WriteOutcome[]`), and `writeSetComplete(ws)` (true only when the set is non-empty AND every surface applied, never an OR-into-one-flag); `markdown-table.cjs` re-exports `Result` from here so existing importers are unaffected; consumed by `milestone.cts`'s `requirements mark-complete` handler to report a structured per-surface (`checkbox`/`traceability`) write-set alongside its existing fields (fixes the structural half of #2140) |
[`docs/CLI-TOOLS.md`](CLI-TOOLS.md) may describe a subset of these modules; when it disagrees with the filesystem, this table and the directory listing are authoritative.

View File

@@ -215,6 +215,8 @@ export default tseslint.config(
'gsd-core/bin/lib/markdown-sectionizer.cjs',
// ADR-2143: tsc-generated runtime artifact — lint the src/markdown-table.cts source.
'gsd-core/bin/lib/markdown-table.cjs',
// ADR-2143: tsc-generated runtime artifact — lint the src/write-set.cts source.
'gsd-core/bin/lib/write-set.cjs',
// ADR-1239 Phase C-1 (#1680): tsc-generated — lint src/embedding-adapter.cts + src/adapter-declarative.cts.
'gsd-core/bin/lib/embedding-adapter.cjs',
'gsd-core/bin/lib/adapter-declarative.cjs',

View File

@@ -3,17 +3,19 @@
* (ADR-2143, epic #2143). Pure functions, Node built-ins only, string-in/value-out,
* no I/O. Compiled by tsc to gsd-core/bin/lib/markdown-table.cjs.
*
* NOTE: the `Result<T>` here is the ADR-2143 parse-result shape {ok,value|reason} —
* deliberately distinct from command-routing-hub's dispatch `Result` {ok,data|kind};
* the two never mix (different modules).
* NOTE: the `Result<T>` here is the ADR-2143 §5 parse-result shape {ok,value|reason},
* now defined once in `./write-set.cjs` (the shared fail-loud + write-set seam) and
* re-exported here so existing importers of `Result` from this module keep working
* unchanged — deliberately distinct from command-routing-hub's dispatch `Result`
* {ok,data|kind}; the two never mix (different modules).
*/
import { collectSection, replaceSection } from './markdown-sectionizer.cjs';
import type { Result } from './write-set.cjs';
export type { Result } from './write-set.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
export type Result<T> = { ok: true; value: T } | { ok: false; reason: string };
/** A parsed GFM pipe table: header column names + rows addressed by column name. */
export interface MarkdownTable {
columns: string[];

View File

@@ -18,6 +18,8 @@ import { platformWriteSync, platformEnsureDir, execGit, retryRenameSync } from '
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { realClock } from './clock.cjs';
import { transitionCore } from './state-transition.cjs';
import { writeSetComplete } from './write-set.cjs';
import type { WriteSet } from './write-set.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ioMod = require('./io.cjs');
const { output, error } = ioMod;
@@ -79,6 +81,18 @@ function cmdRequirementsMarkComplete(cwd: string, reqIdsRaw: string[], raw: bool
// a missing row only counts as drift when a table actually exists.
const hasTable = /^\|\s*Requirement\s*\|/im.test(reqContent);
// ADR-2143 §6 per-surface write-set, tracked PER requirement ID: a
// multi-ID batch must not OR one ID's surface outcome into another's —
// that is the exact #2140 class one level up (an ID whose traceability
// row is absent/unmatched must not have its partial write masked by a
// different ID in the same invocation that fully reconciled). Reported
// additively as `write_set` below — it does not change the existing
// marked_complete/already_complete/not_found/table_unmatched/updated
// computation, which stays byte-for-behaviour identical (#2140's tactical
// fix already surfaces the checkbox-only-partial-write case via
// table_unmatched; this only adds the structured ADR-2143 shape on top).
const writeSet: WriteSet = [];
for (const reqId of reqIds) {
const reqEscaped = escapeRegex(reqId);
@@ -96,6 +110,16 @@ function cmdRequirementsMarkComplete(cwd: string, reqIdsRaw: string[], raw: bool
const tableHit = afterTable !== reqContent;
if (tableHit) reqContent = afterTable;
// ADR-2143 §6 per-ID write-set entries: this ID's checkbox surface is
// always tracked; the traceability surface is tracked only when the file
// has a traceability table at all (same `hasTable` gate the existing
// required-surface logic below uses) — omitted entirely, not a false
// `applied:false`, when no table is required of this file.
writeSet.push({ requirement: reqId, surface: 'checkbox', applied: checkboxHit });
if (hasTable) {
writeSet.push({ requirement: reqId, surface: 'traceability', applied: tableHit });
}
// Coverage of the traceability surface for this ID (computed after any flip).
// hasRow keys on the ID + a second cell (`| ID | <phase> |`) so a bare mention
// of the ID in a non-traceability table does not masquerade as a real row.
@@ -129,6 +153,17 @@ function cmdRequirementsMarkComplete(cwd: string, reqIdsRaw: string[], raw: bool
platformWriteSync(reqPath, reqContent);
}
// ADR-2143 §6: `writeSet` above already carries one WriteOutcome per
// (requirement, surface) this invocation could have written to — per ID,
// not ORed across the batch. `write_set` and `write_set_complete` are
// additive: they do not replace or gate `updated` / `marked_complete` /
// `already_complete` / `not_found` / `table_unmatched`, which remain
// computed exactly as before (see #2140 note above — that fix already
// surfaces a checkbox-only partial write via `table_unmatched`;
// `write_set_complete` is a structured, ADR-2143-shaped read of the SAME
// per-surface, per-ID facts, `false` if ANY id's ANY required surface did
// not apply, since `writeSetComplete` requires EVERY entry to have
// applied, never an OR across surfaces OR across IDs).
output(
{
updated: updated.length > 0,
@@ -137,6 +172,8 @@ function cmdRequirementsMarkComplete(cwd: string, reqIdsRaw: string[], raw: bool
not_found: notFound,
table_unmatched: tableUnmatched,
total: reqIds.length,
write_set: writeSet,
write_set_complete: writeSetComplete(writeSet),
},
raw,
`${updated.length}/${reqIds.length} requirements marked complete`,

View File

@@ -62,43 +62,49 @@ export function deriveProgressFromRoadmap(roadmapContent: string): RoadmapProgre
let totalPhases: number | null = null;
let totalPlans: number | null = null;
try {
// ADR-2143 §3: read the Progress table by column NAME (order/injection-invariant),
// via the markdown-table seam. Scope to the `## Progress` section when present
// (#2012 decoy avoidance); a headingless milestone slice (#1445) falls back to the
// whole input. Requires the canonical Phase/Plans Complete/Status/Completed columns
// in any order (extra columns ignored) — supersedes findTableBySchema's exact-schema lookup.
const progressMatch = roadmapContent.match(/^##[ \t]+Progress\b/im);
let scoped = roadmapContent;
if (progressMatch && progressMatch.index !== undefined) {
const afterHeading = roadmapContent.slice(progressMatch.index);
const nextHeading = afterHeading.search(/\n#{1,2}[ \t]/);
scoped = nextHeading >= 0 ? afterHeading.slice(0, nextHeading) : afterHeading;
// ADR-2143 §5 (fail-loud, no null-swallow): this used to be wrapped in a
// try/catch that silently fell through to the existing (null) values on any
// thrown error. `findTableWithColumns`/`parseMarkdownTable` never throw —
// an unparseable or absent table resolves to `null` /
// `{ ok: false, reason }`, not an exception — so the catch was masking
// nothing but dead code paths. Removed per ADR-2143 §5; the public
// `RoadmapProgress` contract (nulls = absent) is unchanged.
//
// ADR-2143 §3: read the Progress table by column NAME (order/injection-invariant),
// via the markdown-table seam. Scope to the `## Progress` section when present
// (#2012 decoy avoidance); a headingless milestone slice (#1445) falls back to the
// whole input. Requires the canonical Phase/Plans Complete/Status/Completed columns
// in any order (extra columns ignored) — supersedes findTableBySchema's exact-schema lookup.
const progressMatch = roadmapContent.match(/^##[ \t]+Progress\b/im);
let scoped = roadmapContent;
if (progressMatch && progressMatch.index !== undefined) {
const afterHeading = roadmapContent.slice(progressMatch.index);
const nextHeading = afterHeading.search(/\n#{1,2}[ \t]/);
scoped = nextHeading >= 0 ? afterHeading.slice(0, nextHeading) : afterHeading;
}
const table = findTableWithColumns(scoped, ['Phase', 'Plans Complete', 'Status', 'Completed']);
if (table) {
const allRows = table.rows;
const completed = allRows.filter((r) => /^complete$/i.test((r['Status'] ?? '').trim())).length;
completedPhases = completed > 0 ? completed : null;
// Data rows only (exclude 999.x backlog phases). Mirrors init.cts /^999(?:\.|$)/ filter.
const dataRows = allRows.filter((r) => {
const phase = (r['Phase'] ?? '').trim();
return /^\d/.test(phase) && !/^999\b/.test(phase);
});
totalPhases = dataRows.length > 0 ? dataRows.length : null;
let totalPlansSum = 0;
for (const r of allRows) {
const cell = (r['Plans Complete'] ?? '').trim();
const m = /(\d+)\s*\/\s*(\d+)/.exec(cell);
if (m) totalPlansSum += parseInt(m[2], 10);
}
const table = findTableWithColumns(scoped, ['Phase', 'Plans Complete', 'Status', 'Completed']);
if (table) {
const allRows = table.rows;
const completed = allRows.filter((r) => /^complete$/i.test((r['Status'] ?? '').trim())).length;
completedPhases = completed > 0 ? completed : null;
// Data rows only (exclude 999.x backlog phases). Mirrors init.cts /^999(?:\.|$)/ filter.
const dataRows = allRows.filter((r) => {
const phase = (r['Phase'] ?? '').trim();
return /^\d/.test(phase) && !/^999\b/.test(phase);
});
totalPhases = dataRows.length > 0 ? dataRows.length : null;
let totalPlansSum = 0;
for (const r of allRows) {
const cell = (r['Plans Complete'] ?? '').trim();
const m = /(\d+)\s*\/\s*(\d+)/.exec(cell);
if (m) totalPlansSum += parseInt(m[2], 10);
}
totalPlans = totalPlansSum > 0 ? totalPlansSum : null;
}
} catch { /* intentionally empty — fall through to existing values */ }
totalPlans = totalPlansSum > 0 ? totalPlansSum : null;
}
return { completedPhases, totalPhases, totalPlans };
}

55
src/write-set.cts Normal file
View File

@@ -0,0 +1,55 @@
/**
* Write-Set — shared fail-loud parse `Result` and per-surface write-set
* contracts (ADR-2143, epic #2143). Pure, Node built-ins only, no I/O.
* Compiled by tsc to gsd-core/bin/lib/write-set.cjs.
*
* ADR-2143 §5 (fail-loud parsing, no null-swallow): seam parse operations
* and document-model accessors return a typed `Result<T>` — never a bare
* `null` a caller can mistake for "empty but fine." This is the same
* `{ ok: true; value: T } | { ok: false; reason: string }` shape
* `markdown-table.cts` already defined for `parseMarkdownTable` /
* `appendQuickTaskRow`; this module is now the single source of truth for
* it and `markdown-table.cjs` re-exports the type so existing importers of
* `Result` from that module keep working unchanged.
*
* NOTE: deliberately distinct from command-routing-hub's dispatch `Result`
* (`{ok,data}|{ok:false,kind}`) — the two never mix (different modules,
* different shapes, different purposes).
*
* ADR-2143 §6 (write-set results for multi-surface commands, no
* OR-into-one-flag): a command that mutates more than one surface returns
* an explicit per-surface write-set — `{ surface, applied }` outcomes — and
* its top-level "did this fully succeed" signal is true only if EVERY
* surface in the set applied. ORing independent surfaces into a single
* boolean is the direct anti-pattern that let a checkbox-only partial
* write (#2140) report full success.
*/
export type Result<T> = { ok: true; value: T } | { ok: false; reason: string };
/**
* One surface's outcome within a multi-surface write (ADR-2143 §6).
*
* `requirement` identifies which entity/ID this surface outcome belongs to,
* so a multi-entity command's write-set does not collapse independent
* entities into one aggregate — the §6 "no OR into one flag" rule applies
* per entity too, not just per surface.
*/
export interface WriteOutcome {
surface: string;
applied: boolean;
requirement?: string;
}
/** The full set of per-surface outcomes for one multi-surface write. */
export type WriteSet = WriteOutcome[];
/**
* True only if the write-set is non-empty AND every surface in it applied.
* An empty write-set is never "complete" — there is nothing to be complete
* about, so treating it as vacuously true would let a no-op masquerade as
* a full success (the same OR-into-one-flag class ADR-2143 §6 prohibits).
*/
export function writeSetComplete(ws: WriteSet): boolean {
return ws.length > 0 && ws.every((o) => o.applied);
}

View File

@@ -647,6 +647,19 @@ describe('requirements mark-complete command', () => {
assert.ok(content.includes('- [x] **FOO-01**'), 'checkbox should be checked');
// The table is untouched (no FOO-01 row synthesized).
assert.ok(!content.includes('FOO-01 | Phase'), 'no FOO-01 row should be invented');
// ADR-2143 §6 write-set: additive structured read of the same per-surface
// facts — checkbox surface applied (fresh write this run), traceability
// surface did NOT (no row existed to flip), so the set is not complete.
// This does not change `updated`/`marked_complete` above. Per-ID: each
// outcome carries `requirement` so a multi-ID batch cannot OR one ID's
// outcome into another's (the #2140 class one level up).
assert.deepStrictEqual(out.write_set, [
{ requirement: 'FOO-01', surface: 'checkbox', applied: true },
{ requirement: 'FOO-01', surface: 'traceability', applied: false },
]);
assert.strictEqual(out.write_set_complete, false,
'writeSetComplete requires EVERY surface applied, not an OR — a checkbox-only write is not complete');
});
test('#2140 re-run on the half-written file does NOT mask the drift as already_complete', () => {
@@ -664,6 +677,15 @@ describe('requirements mark-complete command', () => {
'nothing flipped on re-run, so not marked_complete');
assert.ok(out.table_unmatched.includes('FOO-01'),
'the drift must still be surfaced as table_unmatched on re-run');
// ADR-2143 §6 write-set: nothing was written THIS run on either surface
// (checkbox was already [x], the row still doesn't exist), so both
// surfaces report applied:false and the set is not complete.
assert.deepStrictEqual(out.write_set, [
{ requirement: 'FOO-01', surface: 'checkbox', applied: false },
{ requirement: 'FOO-01', surface: 'traceability', applied: false },
]);
assert.strictEqual(out.write_set_complete, false);
});
test('#2140 no traceability table at all → still a clean success (no table_unmatched)', () => {
@@ -676,6 +698,104 @@ describe('requirements mark-complete command', () => {
assert.ok(out.marked_complete.includes('NO-TABLE-01'));
assert.ok(!out.table_unmatched || !out.table_unmatched.includes('NO-TABLE-01'),
'no table_unmatched when there is no traceability table');
// ADR-2143 §6 write-set: the traceability surface is omitted entirely
// (not reported as a false applied:false) when the file has no
// traceability table — nothing was required of it. A single-surface
// write-set that fully applied is complete.
assert.deepStrictEqual(out.write_set, [
{ requirement: 'NO-TABLE-01', surface: 'checkbox', applied: true },
]);
assert.strictEqual(out.write_set_complete, true);
});
test('#2140-class multi-ID: one ID\'s partial reconcile is not masked by another ID\'s full one', () => {
// Adversarial-review regression: write_set/write_set_complete were built
// from two INVOCATION-WIDE booleans OR-accumulated across every ID in the
// batch, so a fully-reconciled REQ-02 in the same call could mask a
// checkbox-only partial write on REQ-01. The write-set must be tracked
// PER (requirement, surface) so REQ-01's unmatched traceability row
// cannot be hidden by REQ-02 reconciling cleanly.
writeRequirements(
tmpDir,
`# Requirements
## Coverage
- [ ] **REQ-01**: feature one (no traceability row)
- [ ] **REQ-02**: feature two (has traceability row)
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| REQ-02 | Phase 1 | Pending |
`,
);
const result = runGsdTools('requirements mark-complete REQ-01,REQ-02', tmpDir);
assert.ok(result.success);
const out = JSON.parse(result.output);
// Pre-existing fields are unchanged: both checkboxes flip (marked_complete),
// REQ-01 has no table row so it surfaces as table_unmatched.
assert.deepStrictEqual(out.marked_complete.sort(), ['REQ-01', 'REQ-02']);
assert.deepStrictEqual(out.table_unmatched, ['REQ-01']);
assert.deepStrictEqual(out.not_found, []);
assert.deepStrictEqual(out.already_complete, []);
const content = readRequirements(tmpDir);
assert.ok(content.includes('- [x] **REQ-01**'), 'REQ-01 checkbox should be checked');
assert.ok(content.includes('- [x] **REQ-02**'), 'REQ-02 checkbox should be checked');
assert.ok(content.includes('| REQ-02 | Phase 1 | Complete |'), 'REQ-02 table row should be Complete');
assert.ok(!content.includes('REQ-01 | Phase'), 'no REQ-01 row should be invented');
// The write-set is now per-ID: REQ-01's traceability surface did NOT
// apply (no row to flip) even though REQ-02's did — the aggregate must
// not OR REQ-02's success into REQ-01's outcome.
assert.deepStrictEqual(out.write_set, [
{ requirement: 'REQ-01', surface: 'checkbox', applied: true },
{ requirement: 'REQ-01', surface: 'traceability', applied: false },
{ requirement: 'REQ-02', surface: 'checkbox', applied: true },
{ requirement: 'REQ-02', surface: 'traceability', applied: true },
]);
// Because REQ-01's traceability entry did not apply, the batch as a
// whole is NOT complete — this is the exact bug the fix closes.
assert.strictEqual(out.write_set_complete, false,
'REQ-01\'s unmatched traceability row must not be masked by REQ-02 fully reconciling');
});
test('#2140-class multi-ID: fully-reconciled batch reports write_set_complete:true', () => {
writeRequirements(
tmpDir,
`# Requirements
## Coverage
- [ ] **REQ-01**: feature one
- [ ] **REQ-02**: feature two
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| REQ-01 | Phase 1 | Pending |
| REQ-02 | Phase 1 | Pending |
`,
);
const result = runGsdTools('requirements mark-complete REQ-01,REQ-02', tmpDir);
assert.ok(result.success);
const out = JSON.parse(result.output);
assert.deepStrictEqual(out.marked_complete.sort(), ['REQ-01', 'REQ-02']);
assert.deepStrictEqual(out.table_unmatched, []);
assert.deepStrictEqual(out.write_set, [
{ requirement: 'REQ-01', surface: 'checkbox', applied: true },
{ requirement: 'REQ-01', surface: 'traceability', applied: true },
{ requirement: 'REQ-02', surface: 'checkbox', applied: true },
{ requirement: 'REQ-02', surface: 'traceability', applied: true },
]);
assert.strictEqual(out.write_set_complete, true);
});
test('marks single requirement complete (checkbox + table)', () => {
@@ -692,6 +812,14 @@ describe('requirements mark-complete command', () => {
assert.ok(content.includes('- [x] **TEST-01**'), 'checkbox should be checked');
assert.ok(content.includes('| TEST-01 | Phase 1 | Complete |'), 'table row should be Complete');
assert.ok(content.includes('- [ ] **TEST-02**'), 'TEST-02 should remain unchecked');
// ADR-2143 §6 write-set: both surfaces got a fresh write this run, so the
// write-set is complete.
assert.deepStrictEqual(output.write_set, [
{ requirement: 'TEST-01', surface: 'checkbox', applied: true },
{ requirement: 'TEST-01', surface: 'traceability', applied: true },
]);
assert.strictEqual(output.write_set_complete, true);
});
test('handles mixed prefixes in single call (TEST-XX, REG-XX, INFRA-XX)', () => {

97
tests/write-set.test.cjs Normal file
View File

@@ -0,0 +1,97 @@
'use strict';
/**
* Behavioral tests for write-set.cjs
*
* Module: gsd-core/bin/lib/write-set.cjs
* Exports: writeSetComplete (Result<T> is a pure type re-exported at compile
* time only — nothing to assert on it at runtime beyond markdown-table.cjs's
* own re-export continuing to work, covered by tests/markdown-table.test.cjs).
*
* Covers (ADR-2143 §5/§6):
* - writeSetComplete happy path: all-applied → true, one-not-applied → false
* - BOUNDARY coverage: empty write-set (0 outcomes), single outcome, two outcomes
* - an empty write-set is never "complete" (no vacuous-true on a no-op)
* - fast-check property: writeSetComplete(ws) === (ws.length > 0 && every applied)
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fc = require('./helpers/fast-check-setup.cjs');
const { writeSetComplete } = require('../gsd-core/bin/lib/write-set.cjs');
describe('writeSetComplete', () => {
test('every surface applied → true', () => {
assert.equal(
writeSetComplete([
{ surface: 'checkbox', applied: true },
{ surface: 'traceability', applied: true },
]),
true,
);
});
test('one surface not applied → false (AND across surfaces, never OR)', () => {
assert.equal(
writeSetComplete([
{ surface: 'checkbox', applied: true },
{ surface: 'traceability', applied: false },
]),
false,
);
});
test('no surfaces applied → false', () => {
assert.equal(
writeSetComplete([
{ surface: 'checkbox', applied: false },
{ surface: 'traceability', applied: false },
]),
false,
);
});
// BOUNDARY: 0 outcomes (limit-1 relative to the smallest real set), 1 outcome
// (limit), 2 outcomes (limit+1) — the write-set shape has no upper bound, but
// the meaningful boundary here is "is there anything to be complete about".
test('BOUNDARY: empty write-set (0 outcomes) is never complete', () => {
assert.equal(writeSetComplete([]), false,
'an empty write-set must not vacuously report complete — that would let a ' +
'no-op masquerade as full success, the exact OR-into-one-flag class ADR-2143 §6 prohibits');
});
test('BOUNDARY: single-outcome write-set (1 outcome) — applied true is complete', () => {
assert.equal(writeSetComplete([{ surface: 'checkbox', applied: true }]), true);
});
test('BOUNDARY: single-outcome write-set (1 outcome) — applied false is not complete', () => {
assert.equal(writeSetComplete([{ surface: 'checkbox', applied: false }]), false);
});
test('BOUNDARY: two-outcome write-set (2 outcomes) — both true is complete', () => {
assert.equal(
writeSetComplete([
{ surface: 'checkbox', applied: true },
{ surface: 'traceability', applied: true },
]),
true,
);
});
// ─── Property test ───────────────────────────────────────────────────────────
const outcomeArb = fc.record({
surface: fc.string({ minLength: 1, maxLength: 12 }),
applied: fc.boolean(),
});
test('property: writeSetComplete(ws) === (ws.length > 0 && every outcome applied)', () => {
fc.assert(
fc.property(fc.array(outcomeArb, { maxLength: 8 }), (ws) => {
const expected = ws.length > 0 && ws.every((o) => o.applied);
assert.equal(writeSetComplete(ws), expected);
}),
);
});
});