diff --git a/.prettierignore b/.prettierignore index 1a006f4..ed1767f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -26,3 +26,6 @@ generated/ # The ruled carrier grammar owns these bytes; Prettier rewrites can break its grammar # and fixture↔live byte-parity *.sdp.md + +# Refusal-parity carriers are intentionally malformed inputs read as text by the reifiers. +test/fixtures/import/parity/ diff --git a/AGENTS.md b/AGENTS.md index 4e78293..30f649a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,11 +15,9 @@ claims; **`src/` and tests** are authoritative evidence of current realization. > **Status:** concept ratified · MVP slices 0–5 landed on `main` (plan 10) · post-MVP executable > machinery landed (plan 13) · authoring **carrier ruled** as `.sdp.md` (the carrier ruling, MD-18; -> plan 16) — product Markdown parser and self-hosting landed · **interim carrier -> rule** (the carrier ruling (MD-18), transition clause amended by plan 17): New spec IDs may be -> born Markdown-canonical once the product parser lands; pre-existing IDs and the worked example -> remain TS-canonical until the ruled flip (the product parser, `sdp import`, and the checkout-v1 -> migration) · **what now:** ✅ EXECUTED — phase-1 implementation complete; final audit passed. Build state lives in +> plan 16) — product Markdown parser and self-hosting landed · **canonical-default carrier +> rule:** Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives +> as import source and a lawful per-ID option. · **what now:** ✅ EXECUTED — phase-2 implementation complete; final audit passed. The prior `EXECUTED — phase-1 implementation complete; final audit passed` status remains historic context. The remaining work is the systematic tests-to-executable-specs rewrite and per-doc concept deletion. Build state lives in > **`plans/`** — read the highest > **primary-numbered** plan's status header, plus any **active subplans it (or its parent family) > explicitly designates as current**; ignore unnumbered files and letter-suffixed plans only when @@ -58,7 +56,7 @@ Progressive disclosure — start at the top, follow the pointers down. | `CONTEXT.md` (repo root) | **the vocabulary** — the ratified lean glossary (terms · relations · a worked dialogue · flagged ambiguities); sole source of truth for terminology; the model exposition lives in `00`–`07` | **first, always** | | `jtbd-stories/` | **the jobs (functional spec)** — stable `When / I want / so I can` stories (themes A–H); no personas, because consumers are heterogeneous (humans, CI, CLIs, **AI agents**) | to know *what* we serve | | `docs/concept/00`–`07` (+ README) | **the technical design and the model** — 9 principle-led docs: vision & MVP boundary, founding principles (P1–P10), core model (`02` — the primitive, descriptors, sections, relations), the one graph, authoring & binding, validation & honesty, consumers, roadmap | to know *how* it is designed | -| `docs/concept/DECISIONS.md` | **the why-log** for building the Protocol itself (the ratified-name registry + the MD-series, the R-series, the legacy D1–D6 shorthand, measured evidence) — rationale + open tensions | when a choice looks arbitrary | +| `docs/concept/DECISIONS.md` | **the lean decision registry** — ratified names, one-line glosses, carrying Specs, and the D1–D6 lookup; historical rationale lives in git, plans, and the Specs themselves | when resolving a decision name or following its canonical pointer | | `src/` | **the engine** — `model` (Spec/descriptors/pack/anchors) · `extract` · `graph` · `validate` · `reader` (agent surface) · `projections` (Design Review) · `cli` (`sdp`) · `runner` / `codegen` / `notation` / `adapters` | when implementing or verifying **current engine** behavior | | `examples/checkout-v1` | **the worked MVP example** (TS DSL tracer bullet) — specs, anchors, untracked `generated/` (regenerated in-pipeline); walkthrough in its README | when proving the loop end-to-end | | `explorations/` | **evidence only** (carrier exhibits, executable-example findings) — mapping evidence for design; **never promote spike code into product** | when judging design evidence; not a source tree to ship | @@ -95,7 +93,7 @@ live work is the highest primary-numbered plan under `plans/` (currently the sel | **5** | Polish: the CLI surface resolved (`build` · `validate` · `view`; `explain`/`search` below the second-caller bar), one diagnostic rendering rule, the documented example walkthrough, the clean-repo determinism test. | > **Tracer-bullet discipline.** Author the example specs and anchored code *first*, so the DSL and extractor are -> forced to be usable before they are finished. If the example doesn't typecheck, fix the DSL — not the example. +> forced to be usable before they are finished. If the example stops extracting or validating, fix the carrier or extractor — not the example. ## Two reading conventions diff --git a/CONTEXT.md b/CONTEXT.md index ec62555..f826760 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -4,7 +4,8 @@ > bounded context's vocabulary: **one concept → one word**; the rest are *aliases to avoid*. > > This document carries **terms only**; the model exposition lives in the design docs -> (`docs/concept/00`–`07`), rationale in `docs/concept/DECISIONS.md`. +> (`docs/concept/00`–`07`). The lean decision registry points to carrying Specs; historical +> rationale lives in git, plans, and those Specs. ## Governing rubric [SETTLED] @@ -147,7 +148,7 @@ validated," never "provably correct."** | **oracle** | the authored expected-outcome semantics for a parent's example space — implementation-side, beside the tests, bound by the space's zero-or-one resolving `specOracle` anchor, never extracted; typed against the space contract on input and the derived Outcome union on output (`unspecified` is a first-class answer); rendered surfaces say **"expected outcome"** | a `model`-kind spec · a derived fact | | **witness** | an example whose bound point falls in an outcome class — the evidence that class is covered | — | | **coverage gap** | a region of the example space with no witness (or where the oracle answers `unspecified`) — an informative absence, never a gate | — | -| **`sdp import`** | the one-way gen-1 `.feature` converter: parser half carrier-neutral, document emitter authored once in the winning carrier | round-trip sync | +| **`sdp import`** | one import verb with many source adapters, sharing the document emitter authored once in the winning carrier; the TS→`.sdp.md` adapter landed, the gen-1 `.feature` adapter designed-in and deferred | round-trip sync | **Structural law (point-per-example, MD-17):** an `example` binds exactly **one** point; a table of cases is authoring-surface sugar that statically expands to N sibling examples at extraction, and renderers may project @@ -179,7 +180,7 @@ manifest). **Dropped:** `exemplifies` (= `example` kind + `refines` + `verifies` **discipline** (a lens/filter over the graph, not a phase you pass through) · **phase / iteration / milestone** (descriptive vocabulary, optional roadmap projections, never gates) · **release** (a tagged set — a git-tag projection) · **baseline** (a named approved snapshot; the **signed git tag is the approval -artifact** — approval provenance is git-native, never an authored primitive). +artifact** — approval remains outside the model, never an authored primitive). ## A worked dialogue (the language in use) @@ -232,15 +233,13 @@ artifact** — approval provenance is git-native, never an authored primitive). checks are **conformance checks + honesty checks** · **pre-graph** = upstream of graph derivation in the one validation path (the authored layer before the extractor runs) — a layer checks never live in: validators consume the one graph only, never a second validation path (one validation path, MD-14). -- **Resolved (MD-15):** authored Spec files carry the **`.sdp.ts`** extension (never `.spec.ts`, which every - JS test-runner default glob executes); the model name `Spec` itself was always settled — only the file - serialization changed. -- **Resolved (the carrier ruling, MD-18; transition clause amended by plan 17):** the authored `Spec` - document is **`.sdp.md`** — the Markdown carrier, all eight kinds. New spec IDs may be born - Markdown-canonical once the product parser lands; pre-existing IDs and the worked example remain - TS-canonical until the ruled flip (the product parser, `sdp import`, and the checkout-v1 migration). - The surviving law is **one canonical surface per ID, no mixing** (`04` §1); the `.sdp.ts` extension - law (MD-15) is re-pointed, not repealed — its rationale carries to the `.sdp.md` sibling. +- **Resolved (MD-15):** Markdown Spec files use the **`.sdp.md`** extension by default; **`.sdp.ts`** + identifies the lawful TypeScript carrier without colliding with every JS test-runner's default glob. + The model name `Spec` itself was always settled — only the file serialization changed. +- **Resolved (the carrier ruling, MD-18):** Specs default to Markdown; Packs remain TS until a Pack + syntax ruling; the TS DSL survives as import source and a lawful per-ID option. The surviving law + is **one canonical surface per ID, no mixing** (`04` §1); the `.sdp.ts` extension law (MD-15) is + re-pointed, not repealed — its rationale carries to the `.sdp.md` sibling. - **Resolved (the prose-ownership law, MD-19):** free prose enters the graph as **description values on typed owners** — the owning section or the `Spec` itself (the spec-level narrative slot); never a heading-path store, never a file-only pointer; the edge-text ownership rule is the surface-design diff --git a/README.md b/README.md new file mode 100644 index 0000000..f70d097 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Libar Software Delivery Protocol + +Libar Software Delivery Protocol is a typed, executable, self-validating meta-model of software +delivery: author `Spec` documents in the repository, derive one graph, and check conformance and +honesty. + +**Carrier rule:** Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS +DSL survives as import source and a lawful per-ID option. + +Read the [concept](docs/concept/README.md), the [ubiquitous language](CONTEXT.md), and the +[checkout-v1 walkthrough](examples/checkout-v1/README.md). diff --git a/check-carrier-interim.mjs b/check-carrier-rule.mjs similarity index 62% rename from check-carrier-interim.mjs rename to check-carrier-rule.mjs index 4cb03fd..9aafe79 100644 --- a/check-carrier-interim.mjs +++ b/check-carrier-rule.mjs @@ -2,32 +2,26 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -// check-carrier-interim — the structured consistency gate for the interim carrier rule (the -// carrier ruling (MD-18), transition clause amended by the self-hosting plan). +// check-carrier-rule — the structured consistency gate for the canonical-default carrier rule +// (the carrier ruling, MD-18). // // Asserts, and NAMES each disagreeing surface on failure: -// 1. the SAME interim-rule sentence in the three current-rule surfaces (the decision diary, -// the glossary, the agent handbook): new IDs may be born Markdown-canonical once the -// product parser lands, while pre-existing IDs and the worked example stay TS-canonical -// until the ruled flip — the canonical flip/import/migration is NOT claimed complete; +// 1. the SAME post-flip rule sentence in the three current-rule surfaces (the decision diary, +// the glossary, the agent handbook): Specs default to Markdown; Packs remain TS until a +// Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option; // 2. the obsolete sole-canonical-TS wording gone from the diary and the glossary, and the // surviving law (one canonical surface per ID, no mixing) still present in the diary; // 3. the extension-law re-point (the `.sdp.ts` extension law, MD-15) present in the diary; // 4. the SAME logical/physical relations distinction in the core-model doc and JS-A1: // relations stay OPTIONAL in the logical Spec; the physical Markdown envelope writes // `relations: {}` when the logical set is empty — honest carrier syntax, never a new -// logical relation requirement; -// 5. every obligation row the self-hosting phase added to its docket. +// logical relation requirement. // -// Usage: node check-carrier-interim.mjs [rootDir] [planFile] +// Usage: node check-carrier-rule.mjs [rootDir] // rootDir — tree holding the operative surfaces; defaults to this repo root. QA passes a // temp tree of mutated copies to prove disagreeing surfaces are named. -// planFile — the docket file, relative to rootDir. The default is assembled in parts so this -// durable tracked file carries no numbered plan-path token (check-temporal bans -// those from tracked files). const rootDir = process.argv[2] ?? dirname(fileURLToPath(import.meta.url)); -const planFile = process.argv[3] ?? ["plans", "17-self-hosting-v1.md"].join("/"); const SURFACES = { decisions: "docs/concept/DECISIONS.md", @@ -37,11 +31,11 @@ const SURFACES = { jsA1: "jtbd-stories/01-capture-and-evolve-intent.md", }; -// The one interim carrier rule. Verbatim (modulo whitespace) in the diary, the glossary, and -// the agent handbook — never paraphrased per surface. The trailing period is surface-local +// The canonical-default rule. Verbatim (modulo whitespace) in the diary, the glossary, and the +// agent handbook — never paraphrased per surface. The trailing period is surface-local // punctuation (the AGENTS status row continues with its `·` separator), so it is not asserted. -const INTERIM_RULE = - "New spec IDs may be born Markdown-canonical once the product parser lands; pre-existing IDs and the worked example remain TS-canonical until the ruled flip (the product parser, `sdp import`, and the checkout-v1 migration)"; +const CARRIER_RULE = + "Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option"; // The owner-confirmed logical/physical relations distinction. Verbatim in the core-model doc // and JS-A1. @@ -52,16 +46,6 @@ const SURVIVING_LAW = "one canonical surface per ID, no mixing"; const OBSOLETE_SOLE_CANONICAL = "stays sole-canonical"; const EXTENSION_LAW_REPAIR = "re-pointed, not repealed"; -const DOCKET_ROWS = [ - "Public/package API proof", - "Temporal-scan coverage", - "Root generated-state isolation", - "Clean-clone proof", - "JTBD carrier repair", - "MD-15 wording repair", - "Four-gate review ledger", -]; - // Sameness is about WORDS, so markdown quoting furniture is stripped before whitespace // normalization: a sentence wrapped inside a `>` blockquote (the AGENTS status row, the // core-model carrier note) must read identically to its plain-paragraph twins. @@ -81,7 +65,7 @@ const expectOmits = (surface, needle, why) => { }; for (const surface of [SURFACES.decisions, SURFACES.glossary, SURFACES.agents]) { - expectContains(surface, INTERIM_RULE, "missing the interim carrier-rule sentence"); + expectContains(surface, CARRIER_RULE, "missing the canonical-default carrier-rule sentence"); } expectContains( SURFACES.decisions, @@ -113,17 +97,13 @@ expectContains( RELATIONS_DISTINCTION, "missing the logical/physical relations distinction", ); -for (const row of DOCKET_ROWS) { - expectContains(planFile, row, `docket obligation missing: ${row}`); -} - if (failures.length > 0) { - console.error("check-carrier-interim — disagreeing surfaces:\n"); + console.error("check-carrier-rule — disagreeing surfaces:\n"); console.error(failures.join("\n")); process.exit(1); } console.log( - "check-carrier-interim — all surfaces agree: interim carrier rule, relations distinction, docket obligations.", + "check-carrier-rule — all surfaces agree: canonical-default carrier rule and relations distinction.", ); process.exit(0); diff --git a/check-carrier-truth.mjs b/check-carrier-truth.mjs index 3b90bff..06b3e99 100644 --- a/check-carrier-truth.mjs +++ b/check-carrier-truth.mjs @@ -9,12 +9,11 @@ import { fileURLToPath } from "node:url"; // // Three assertion families, each NAMING the disagreeing surface on failure: // A. CLAIMS — every changed claim and its intended state: the repaired wording present, the -// obsolete sole-TS-canonical wording gone, per file. The interim rule is mirrored exactly: -// new spec IDs may be born Markdown-canonical once the product parser lands (it has -// landed); pre-existing IDs and the worked example remain TS-canonical until the ruled -// flip; `sdp import`, the checkout-v1 migration, and the canonical-default flip are -// deferred. P5's static/side-effect-free principle and the one graph/one validation path -// are preserved while their carrier representations are plural, and concept 04 names the +// obsolete sole-TS-canonical and interim-transition wording gone, per file. The +// canonical-default rule is mirrored exactly: Specs default to Markdown; Packs remain TS +// until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID +// option. P5's static/side-effect-free principle and the one graph/one validation path are +// preserved while their carrier representations are plural, and concept 04 names the // per-carrier degradation asymmetry (TS: property-level drop with warning; Markdown: // all-or-nothing per document under the four hard finding IDs). // B. OBSOLETE SWEEP — the scanned corpus carries NO active unqualified sole-TS claim at all, @@ -27,8 +26,8 @@ import { fileURLToPath } from "node:url"; // // Scan scope: docs/concept/*.md and jtbd-stories/*.md (enumerated from disk), plus CONTEXT.md. // Deliberately excluded: docs/concept/DECISIONS.md (the dated decision diary — historical -// records by genre; its active carrier wording is pinned by check-carrier-interim.mjs), -// AGENTS.md (same interim gate; owned by a parallel flight), plans/ + reviews/ + explorations/ +// records by genre; its active carrier wording is pinned by check-carrier-rule.mjs), +// AGENTS.md (same carrier gate; owned by a parallel flight), plans/ + reviews/ + explorations/ // (per-session records and exhibits, historical by genre), src/ + test/ (code, not concept/JTBD // prose; its TypeScript mentions are implementation, not authoring guidance). // @@ -38,8 +37,7 @@ import { fileURLToPath } from "node:url"; const rootDir = process.argv[2] ?? dirname(fileURLToPath(import.meta.url)); -// Sameness is about WORDS: markdown quoting furniture is stripped and whitespace collapsed so a -// sentence wrapped in a `>` blockquote or re-wrapped by a formatter still reads identically. +// Sameness strips `>` blockquote markers and collapses whitespace before comparing text. const norm = (text) => text.replace(/^>\s?/gm, "").replace(/\s+/g, " "); const failures = []; @@ -65,14 +63,11 @@ const CLAIMS = [ }, { file: "docs/concept/00-vision-scope-and-mvp-boundary.md", - label: - "carrier status states the interim rule precisely (new IDs .sdp.md, checkout TS-canonical, import/migration/flip deferred)", + label: "carrier status states the canonical-default rule precisely", present: [ - "new spec IDs may be born Markdown-canonical (`.sdp.md`) — the product parser has landed —", - "remain TS-canonical (`.sdp.ts`) until the ruled flip", - "`sdp import`, the checkout-v1 migration, and the canonical-default flip are deferred", + "Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option", ], - absent: [], + absent: ["until the ruled flip"], }, { file: "docs/concept/00-vision-scope-and-mvp-boundary.md", @@ -154,12 +149,15 @@ const CLAIMS = [ }, { file: "docs/concept/04-authoring-and-binding.md", - label: "one canonical surface per ID states the interim rule verbatim plus the deferrals", + label: "one canonical surface per ID states the canonical-default rule verbatim", present: [ - "New spec IDs may be born Markdown-canonical once the product parser lands; pre-existing IDs and the worked example remain TS-canonical until the ruled flip (the product parser, `sdp import`, and the checkout-v1 migration)", - "the canonical-default flip remain deferred", + "Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option", + ], + absent: [ + "In the MVP that is always the TS DSL", + "When Gherkin arrives", + "until the ruled flip", ], - absent: ["In the MVP that is always the TS DSL", "When Gherkin arrives"], }, { file: "docs/concept/04-authoring-and-binding.md", @@ -204,12 +202,12 @@ const CLAIMS = [ }, { file: "docs/concept/07-mvp-roadmap-and-open-questions.md", - label: "the carrier addendum reconciles the CORE map with the interim rule", + label: "the carrier addendum reconciles the CORE map with the canonical-default rule", present: [ "Carrier addendum (post-MVP)", - "New spec IDs may be born Markdown-canonical once the product parser lands; pre-existing IDs and the worked example remain TS-canonical until the ruled flip", + "Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option", ], - absent: [], + absent: ["until the ruled flip"], }, { file: "docs/concept/07-mvp-roadmap-and-open-questions.md", @@ -231,13 +229,26 @@ const CLAIMS = [ }, { file: "docs/concept/README.md", - label: "the legend write-path states the interim rule verbatim plus the deferrals", + label: "the legend write-path states the canonical-default rule verbatim", present: [ "edit the canonical carrier + git", - "new spec IDs may be born Markdown-canonical once the product parser lands; pre-existing IDs and the worked example remain TS-canonical until the ruled flip (the product parser, `sdp import`, and the checkout-v1 migration)", - "the canonical-default flip are deferred", + "Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option", + ], + absent: ["The MVP write-path is simply **edit TypeScript + git**", "until the ruled flip"], + }, + { + file: "CONTEXT.md", + label: "the sdp import glossary definition retains its many-source-adapters amendment", + present: ["one import verb with many source adapters"], + absent: [], + }, + { + file: "README.md", + label: "the package README carries the canonical-default rule", + present: [ + "Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option", ], - absent: ["The MVP write-path is simply **edit TypeScript + git**"], + absent: [], }, { file: "jtbd-stories/01-capture-and-evolve-intent.md", @@ -345,6 +356,7 @@ const OBSOLETE = [ "every `*.sdp.ts` under the extraction root", "ts-morph extractor", "lives in a `*.sdp.ts` file under the extraction root", + "New spec IDs may be born Markdown-canonical once the product parser lands; pre-existing IDs and the worked example remain TS-canonical until the ruled flip (the product parser, `sdp import`, and the checkout-v1 migration)", ]; const normalizedBodies = new Map(); @@ -376,7 +388,7 @@ const PLURAL = "an explicitly plural carrier representation"; // so the audit survives formatter re-wrapping. Every rule MUST classify at least one line — a // rule that matches nothing is a stale audit entry and fails. const RULES = [ - // CONTEXT.md — the ratified glossary (repaired at the interim-rule task). + // CONTEXT.md — the ratified glossary (re-pinned at the canonical-default flip). { file: "CONTEXT.md", includes: "the TS DSL remaining the import source and a lawful per-ID option", @@ -385,10 +397,29 @@ const RULES = [ { file: "CONTEXT.md", includes: '"DSL" (reserved for the TS DSL)', category: STILL_SUPPORTED }, { file: "CONTEXT.md", - includes: "authored Spec files carry the **`.sdp.ts`** extension", + includes: "Markdown Spec files use the **`.sdp.md`** extension by default", + category: PLURAL, + }, + { + file: "CONTEXT.md", + includes: "identifies the lawful TypeScript carrier", category: STILL_SUPPORTED, }, - { file: "CONTEXT.md", includes: "TS-canonical until the ruled flip", category: PLURAL }, + { + file: "CONTEXT.md", + includes: "the TS DSL survives as import source and a lawful per-ID option", + category: PLURAL, + }, + { + file: "CONTEXT.md", + includes: "many source adapters, sharing the document emitter", + category: PLURAL, + }, + { + file: "CONTEXT.md", + includes: "Packs remain TS until a Pack", + category: PLURAL, + }, { file: "CONTEXT.md", includes: "the `.sdp.ts` extension", category: STILL_SUPPORTED }, // 00 — vision, scope, MVP boundary. @@ -404,7 +435,7 @@ const RULES = [ }, { file: "docs/concept/00-vision-scope-and-mvp-boundary.md", - includes: "remain TS-canonical (`.sdp.ts`) until the ruled flip", + includes: "the TS DSL survives as import source and a lawful per-ID option", category: PLURAL, }, { @@ -414,18 +445,17 @@ const RULES = [ }, { file: "docs/concept/00-vision-scope-and-mvp-boundary.md", - includes: "`.sdp.md` or `.sdp.ts` per ID", + includes: "`.sdp.md` by default; `.sdp.ts` remains lawful per ID", category: PLURAL, }, { file: "docs/concept/00-vision-scope-and-mvp-boundary.md", - includes: "TypeScript `.sdp.ts` for the worked example", + includes: "TypeScript `.sdp.ts` remains lawful per ID", category: PLURAL, }, { file: "docs/concept/00-vision-scope-and-mvp-boundary.md", - includes: - "TypeScript (`.sdp.ts`) for pre-existing IDs and the worked example until the ruled flip", + includes: "TypeScript (`.sdp.ts`) a lawful per-ID option", category: PLURAL, }, @@ -482,8 +512,7 @@ const RULES = [ // 04 — authoring & binding. { file: "docs/concept/04-authoring-and-binding.md", - includes: - "the **TypeScript Spec DSL (`.sdp.ts`)**, the MVP's carrier, still canonical for pre-existing IDs", + includes: "the **TypeScript Spec DSL (`.sdp.ts`)**, an import source and lawful per-ID option", category: PLURAL, }, { @@ -508,12 +537,7 @@ const RULES = [ }, { file: "docs/concept/04-authoring-and-binding.md", - includes: "New spec IDs may be born Markdown-canonical once the product parser lands", - category: PLURAL, - }, - { - file: "docs/concept/04-authoring-and-binding.md", - includes: "TS DSL survives as the import source and a lawful per-ID option (the interim rule", + includes: "TS DSL survives as import source and a lawful per-ID option", category: STILL_SUPPORTED, }, { @@ -558,12 +582,12 @@ const RULES = [ }, { file: "docs/concept/07-mvp-roadmap-and-open-questions.md", - includes: "TypeScript `.sdp.ts` for the worked example", + includes: "TypeScript `.sdp.ts` remains lawful per ID", category: PLURAL, }, { file: "docs/concept/07-mvp-roadmap-and-open-questions.md", - includes: "TypeScript for the checkout worked example", + includes: "TypeScript a lawful per-ID option", category: PLURAL, }, { @@ -573,7 +597,7 @@ const RULES = [ }, { file: "docs/concept/07-mvp-roadmap-and-open-questions.md", - includes: "New spec IDs may be born Markdown-canonical once the product parser lands", + includes: "the TS DSL survives as import source and a lawful per-ID option", category: PLURAL, }, { @@ -591,7 +615,7 @@ const RULES = [ }, { file: "docs/concept/README.md", - includes: "the TypeScript DSL (`.sdp.ts`, the worked example's carrier until the ruled flip)", + includes: "the TypeScript DSL (`.sdp.ts`, import source and lawful per-ID option)", category: PLURAL, }, { @@ -606,14 +630,14 @@ const RULES = [ }, { file: "docs/concept/README.md", - includes: "remain TS-canonical until the ruled flip", + includes: "the TS DSL survives as import source and a lawful per-ID option", category: PLURAL, }, // JTBD stories. { file: "jtbd-stories/01-capture-and-evolve-intent.md", - includes: "`*.sdp.ts` for a TS-canonical pre-existing ID", + includes: "with `*.sdp.ts` lawful per ID", category: PLURAL, }, { @@ -628,7 +652,7 @@ const RULES = [ }, { file: "jtbd-stories/README.md", - includes: "the TS DSL for the checkout worked example; the ruled Markdown carrier for new IDs", + includes: "the TS DSL as an import source and lawful per-ID option", category: PLURAL, }, { diff --git a/check-self-hosting-gates.mjs b/check-self-hosting-gates.mjs index fe98e10..3bfe963 100644 --- a/check-self-hosting-gates.mjs +++ b/check-self-hosting-gates.mjs @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -40,6 +40,7 @@ const defaultRoot = dirname(fileURLToPath(import.meta.url)); const rootDir = process.argv[2] ?? defaultRoot; const planPath = ["plans", "17-self-hosting-v1.md"].join("/"); +const plan18Path = ["plans", "18-self-hosting-phase-2.md"].join("/"); const plan16Path = ["plans", "16-carrier-ruling.md"].join("/"); const agentsPath = "AGENTS.md"; const decisionsPath = "docs/concept/DECISIONS.md"; @@ -49,6 +50,10 @@ const glossaryPath = "CONTEXT.md"; // ledger must agree with them field for field. The shared gate date is assembled in parts. const GATE_DATE = ["2026", "07", "18"].join("-"); const GATE_4_CORRECTION = "24f9978: docs(concept): record the landed prose projection in 06"; +const PHASE2_GATES = [ + { gate: "G1", sha: "f06f14d" }, + { gate: "G2", sha: "df444f2" }, +]; const GATES = [ { gate: "1", meaning: "schema freeze", sha: "aca79090529c2f6625ceafc78f33e16da81bfcb1" }, { gate: "2", meaning: "corpus/readiness", sha: "cdb68fc1564c9167ebc0372ba8f8599a97df4393" }, @@ -79,6 +84,7 @@ const agents = read(agentsPath); const decisions = read(decisionsPath); const glossary = read(glossaryPath); const plan16 = read(plan16Path); +const plan18 = existsSync(join(rootDir, plan18Path)) ? read(plan18Path) : null; // --------------------------------------------------------------------------- // 1. The docket: all 25 obligations are dispositioned. @@ -251,9 +257,7 @@ for (const [surface, text] of [ // --------------------------------------------------------------------------- for (const needle of [ - "### MD-20", "the strict consumer-exclusion contract", - "### MD-21", "the envelope-grammar ownership posture", "| MD-20 |", "| MD-21 |", @@ -274,6 +278,56 @@ for (const needle of ["three-part-test dispositions", "MD-20", "MD-21"]) { ); } +// --------------------------------------------------------------------------- +// 6. Phase-2 plan scaffold: the G1-G8 process ledger is present and usable. +// --------------------------------------------------------------------------- + +if (plan18 !== null) { + const phase2LedgerStart = plan18.indexOf("## (m) §10 Gate ledger G1-G8"); + const phase2LedgerEnd = plan18.indexOf("## (n)", phase2LedgerStart + 10); + const phase2Ledger = + phase2LedgerStart === -1 + ? "" + : plan18.slice(phase2LedgerStart, phase2LedgerEnd === -1 ? undefined : phase2LedgerEnd); + + if (phase2LedgerStart === -1) { + failures.push(`${plan18Path} — missing the G1-G8 gate ledger`); + } else { + expectContains( + plan18Path, + phase2Ledger, + "never graph content", + "the G1-G8 ledger must declare itself process evidence, never graph content", + ); + + for (const gate of Array.from({ length: 8 }, (_, index) => `G${index + 1}`)) { + const row = phase2Ledger.split("\n").find((line) => line.startsWith(`| ${gate} |`)) ?? ""; + if (row === "") { + failures.push(`${plan18Path} — ledger row for ${gate} is missing`); + continue; + } + + const cells = row + .split("|") + .map((cell) => cell.trim()) + .slice(1, -1); + if (cells.length !== 4 || cells.some((cell) => cell === "")) { + failures.push(`${plan18Path} — ledger row for ${gate} is empty or malformed`); + } + if (!/(pending|planned|accepted|done|deferred|not started)/iu.test(cells[3] ?? "")) { + failures.push(`${plan18Path} — ledger row for ${gate} has no valid disposition state`); + } + + const phase2Gate = PHASE2_GATES.find((entry) => entry.gate === gate); + if (phase2Gate !== undefined && !norm(row).includes(phase2Gate.sha)) { + failures.push( + `${plan18Path} — ledger row for ${gate} does not contain accepted SHA ${phase2Gate.sha}`, + ); + } + } + } +} + // --------------------------------------------------------------------------- // Temporal dry-run leg (default root only — a temp tree is not a git checkout). // --------------------------------------------------------------------------- @@ -303,6 +357,7 @@ const report = { agents: agentsPath, decisions: decisionsPath, glossary: glossaryPath, + phase2Plan: plan18 === null ? null : plan18Path, }, temporal, docket: { @@ -323,6 +378,7 @@ const report = { { meaning, disposition: "accepted", date: GATE_DATE, sha, corrections }, ]), ]), + phase2Ledger: plan18 === null ? "not present" : "G1-G8 scaffold checked", }; console.log(JSON.stringify(report, null, 2)); process.exit(0); diff --git a/check-temporal.mjs b/check-temporal.mjs index 1a09e5a..8d27a49 100644 --- a/check-temporal.mjs +++ b/check-temporal.mjs @@ -2,12 +2,14 @@ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; // The temporal-token guard: durable artifacts carry current truth, so calendar and session tokens -// (session/wave/fold handles, ISO dates, numbered plan-file refs) are banned from every tracked -// and nonignored untracked file. Only the temporal-by-genre artifacts are exempt: the dated -// decision diary, the per-session plan done-records, the archived reviews, the dated exploration -// records (verbatim exhibits, diary-dated like DECISIONS) — plus the machine-generated lockfile -// (derived, not authored prose). `Slice N` / `Phase 0` (roadmap-relative capability names) and -// `MD-n` citations are allowed. +// (session/wave/fold handles, ISO dates, numbered plan-file refs) are banned from every delivery +// artifact — tracked files and nonignored untracked files. An ignored agent workspace is not a +// delivery artifact and never reaches the sweep: enumeration honours `.gitignore`, so no exemption +// is written for one. Only the temporal-by-genre artifacts are exempt: the dated decision diary, +// the per-session plan done-records, the archived reviews, the dated exploration records (verbatim +// exhibits, diary-dated like DECISIONS) — plus the machine-generated lockfile (derived, not +// authored prose). `Slice N` / `Phase 0` (roadmap-relative capability names) and `MD-n` citations +// are allowed. const pattern = "Session[ -][0-9]|Wave[- ][A-Z]|Fold-[A-Z]|deferredInSession|plans/[0-9]+|20[0-9]{2}-[0-9]{2}-[0-9]{2}"; const expression = new RegExp(pattern, "u"); diff --git a/docs/concept/00-vision-scope-and-mvp-boundary.md b/docs/concept/00-vision-scope-and-mvp-boundary.md index 3ed7cfa..560cbd4 100644 --- a/docs/concept/00-vision-scope-and-mvp-boundary.md +++ b/docs/concept/00-vision-scope-and-mvp-boundary.md @@ -56,11 +56,11 @@ The MVP proves the founding principle — *one regenerable graph derived from th - **One generated read-only view**: a derived, regenerable human-readable projection (spec tree + per-spec detail with readiness, relations, impact list, source links). - **Bidirectional spec↔test trace**: query "what verifies this spec?" and "what does this test cover?" from the graph. -**Carrier status (the interim rule — the carrier ruling, MD-18):** new spec IDs may be born Markdown-canonical (`.sdp.md`) — the product parser has landed — while pre-existing IDs and the checkout-v1 worked example remain TS-canonical (`.sdp.ts`) until the ruled flip; `sdp import`, the checkout-v1 migration, and the canonical-default flip are deferred. The TS DSL survives as the import source and a lawful per-ID option (one canonical surface per ID, no mixing — `04` §1). +**Carrier status (the carrier ruling, MD-18):** Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option. One canonical surface per ID, no mixing (`04` §1). -**The MVP write-path is "edit the canonical carrier + git."** No patch loop, no codemod. You change a spec document in its canonical carrier (`.sdp.md` or `.sdp.ts` per ID — the carrier status above) or an anchor, re-run `sdp build`, and the view updates. CI rejects PRs that break links or state readiness the spec has not earned. +**The MVP write-path is "edit the canonical carrier + git."** No patch loop, no codemod. You change a spec document in its canonical carrier (`.sdp.md` by default; `.sdp.ts` remains lawful per ID) or an anchor, re-run `sdp build`, and the view updates. CI rejects PRs that break links or state readiness the spec has not earned. -**"Done" for the MVP:** you can write specs in the canonical carrier (Markdown `.sdp.md` for new IDs, TypeScript `.sdp.ts` for the worked example — the carrier status above), anchor implementing code and tests with stable IDs, run `sdp build`, get a graph that passes the conformance + honesty checks, and open a generated view that correctly shows intent, implementation links, verification status, and impact — and CI rejects the PR if any of it is broken or incomplete. Delete `generated/` and rebuild identically. +**"Done" for the MVP:** you can write specs in the canonical carrier (Markdown `.sdp.md` by default; TypeScript `.sdp.ts` remains lawful per ID — the carrier status above), anchor implementing code and tests with stable IDs, run `sdp build`, get a graph that passes the conformance + honesty checks, and open a generated view that correctly shows intent, implementation links, verification status, and impact — and CI rejects the PR if any of it is broken or incomplete. Delete `generated/` and rebuild identically. The detailed slice plan and "what done looks like" checklist live in [`07-mvp-roadmap-and-open-questions.md`](./07-mvp-roadmap-and-open-questions.md). @@ -101,4 +101,4 @@ These are not "later"; they are deliberately *out*: ## 6. The one-breath scope statement -Libar Software Delivery Protocol is an executable meta-model of the software-delivery process: one `Spec` primitive positioned by three descriptors (`kind` · `altitude` · `readiness`), authored in a ruled carrier — Markdown (`.sdp.md`) for new IDs, TypeScript (`.sdp.ts`) for pre-existing IDs and the worked example until the ruled flip — with realization signals as **derived delivery facts**, statically extracted from both carriers into one deterministic, **`claim`-aware** graph that is the sole read model, kept honest by **conformance + honesty checks**, and projected into human- and AI-facing surfaces — with git history as the event log. The MVP is the smallest honest vertical that proves this on one bounded context; everything richer is designed-for and deferred. +Libar Software Delivery Protocol is an executable meta-model of the software-delivery process: one `Spec` primitive positioned by three descriptors (`kind` · `altitude` · `readiness`), authored in a ruled carrier — Markdown (`.sdp.md`) by default, with TypeScript (`.sdp.ts`) a lawful per-ID option and Packs remaining TS until a Pack syntax ruling — with realization signals as **derived delivery facts**, statically extracted from both carriers into one deterministic, **`claim`-aware** graph that is the sole read model, kept honest by **conformance + honesty checks**, and projected into human- and AI-facing surfaces — with git history as the event log. The MVP is the smallest honest vertical that proves this on one bounded context; everything richer is designed-for and deferred. diff --git a/docs/concept/04-authoring-and-binding.md b/docs/concept/04-authoring-and-binding.md index fdbdb1b..a31382c 100644 --- a/docs/concept/04-authoring-and-binding.md +++ b/docs/concept/04-authoring-and-binding.md @@ -1,6 +1,6 @@ # 04 — Authoring & Binding -How truth gets into the repo. Authoring has two ruled **carriers**, both framework-neutral: the **Markdown carrier (`.sdp.md`)** — ruled for all eight kinds (the carrier ruling, MD-18) and canonical for new IDs now that the product parser has landed — and the **TypeScript Spec DSL (`.sdp.ts`)**, the MVP's carrier, still canonical for pre-existing IDs and the worked example until the ruled flip. **Generic source anchors** bind code under either carrier. Richer surfaces (a Gherkin-like carrier, the interactive harness UI) are named in §4 so the model accommodates them — the harness UI **ASPIRATIONAL**, the carrier competition now ruled — while the carrier-independent executable machinery beneath them is landed (CORE). +How truth gets into the repo. Authoring has two ruled **carriers**, both framework-neutral: the **Markdown carrier (`.sdp.md`)** — the default for all eight kinds (the carrier ruling, MD-18) — and the **TypeScript Spec DSL (`.sdp.ts`)**, an import source and lawful per-ID option. Packs remain TS until a Pack syntax ruling. **Generic source anchors** bind code under either carrier. Richer surfaces (a Gherkin-like carrier, the interactive harness UI) are named in §4 so the model accommodates them — the harness UI **ASPIRATIONAL**, the carrier competition now ruled — while the carrier-independent executable machinery beneath them is landed (CORE). Realises **P5** (statically extractable), **P6** (ID-linked), **P9/P10** (anchors are anchored bindings, not intent), and the epistemic boundary from `01`. @@ -60,7 +60,7 @@ Two sanctioned moves, both keeping the same IDs (P4): ### One canonical surface per ID -For any given spec ID, exactly one surface is canonical — no mixing per ID. New spec IDs may be born Markdown-canonical once the product parser lands; pre-existing IDs and the worked example remain TS-canonical until the ruled flip (the product parser, `sdp import`, and the checkout-v1 migration). The product parser has landed, so new IDs may be `.sdp.md` today; `sdp import`, the checkout-v1 migration, and the canonical-default flip remain deferred, and after the flip the TS DSL survives as the import source and a lawful per-ID option (the interim rule — the carrier ruling, MD-18, transition clause amended by plan 17). A per-ID canonical-surface config decides which surface is canonical for a spec; the other is a generated read-only view. +For any given spec ID, exactly one surface is canonical — no mixing per ID. Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option. A per-ID canonical-surface config is designed-for and deferred (ASPIRATIONAL); the current realization is file-existence-only: the surface that exists is canonical, and the other is a generated read-only view. --- diff --git a/docs/concept/05-validation-and-honesty.md b/docs/concept/05-validation-and-honesty.md index eeef629..c8d0ac4 100644 --- a/docs/concept/05-validation-and-honesty.md +++ b/docs/concept/05-validation-and-honesty.md @@ -103,7 +103,7 @@ Three laws bound the table: **The `ready` floor is earned, not asserted** — and it is **not** a delivery fact. The floor may require that anchors *resolve* (so `implemented` is *derivable*); it **never** requires the spec to *be* `implemented`. Readiness is a *stated position* about the design; delivery facts are observations about the code (`02` §2). Higher floors degrade gracefully if their structural inputs are absent. -**`ready` is the structural floor plus a human's `declared` statement — not a record that a review occurred.** The Design Review (`06` §5) is *where* a human typically decides, but the graph stores **no** review/approval fact, and the validator never checks one — that would be the workflow-gating the honesty guardrail forbids (§1). Where approval provenance matters — a baseline — it is **git-native** (authorship + a signed tag, `03`), not an authored primitive (approval / RBAC stays outside the model, `07`). +**`ready` is the structural floor plus a human's `declared` statement — not a record that a review occurred.** The Design Review (`06` §5) is *where* a human typically decides, but the graph stores **no** review/approval fact, and the validator never checks one — that would be the workflow-gating the honesty guardrail forbids (§1). Where approval matters — a baseline — the signed git tag is the approval artifact (authorship + tag, `03`), not an authored primitive (approval / RBAC stays outside the model, `07`). > **Stated vs derived readiness.** The author *states* a `readiness`; the same floor table also yields a *derived* readiness — the highest rung whose cumulative clauses pass. Both ship in the MVP: the floor check fails a stated rung the structure does not earn, and the Design Review renders stated beside the floor reached, naming the first unmet clause (the evaluator reports which clause fails). The divergence banner fires only in the dishonest direction — derived *at-or-above* stated is ordinary information, because the floor is a floor, never a quota that nags upward. The principle: *the stated rung is not trusted, it is verified.* (Note the verb: readiness is **stated/asserted**, never "claimed" — "claim" is reserved for the `claim` taxonomy in `04`.) diff --git a/docs/concept/07-mvp-roadmap-and-open-questions.md b/docs/concept/07-mvp-roadmap-and-open-questions.md index 43e8cd2..6a672e1 100644 --- a/docs/concept/07-mvp-roadmap-and-open-questions.md +++ b/docs/concept/07-mvp-roadmap-and-open-questions.md @@ -25,14 +25,14 @@ Package: a single **`@libar-dev/software-delivery-protocol`** (DSL + types, anch ### What "done" looks like -- You write specs in the canonical carrier (Markdown `.sdp.md` for new IDs, TypeScript `.sdp.ts` for the worked example), anchor implementing code and tests with stable IDs, run `sdp build`, and get a valid graph. +- You write specs in the canonical carrier (Markdown `.sdp.md` by default; TypeScript `.sdp.ts` remains lawful per ID), anchor implementing code and tests with stable IDs, run `sdp build`, and get a valid graph. - Conformance + honesty checks pass/fail with clear messages; CI rejects a PR that breaks links or states readiness the spec has not earned. - The agent surface (the `reader`) exposes the graph to an agent (entry adapters + impact); the Design Review / generated view shows linked specs + implementations + tests with correct readiness and impact lists. - Edits flow as *intent → agent edits source → git → conformance checks*; there is no patch subsystem. - Changing a spec or anchor and re-running produces an updated graph and view. - Delete `generated/` and rebuild — **byte-identical** (determinism, P3). -**North Star (one sentence):** *On a small bounded context, an engineer writes specs in the ruled carrier (Markdown for new IDs, TypeScript for the checkout worked example), anchors the implementing code and tests with stable IDs, runs `sdp build`, gets a graph that passes the conformance + honesty checks and that an agent can read as the agent surface, and opens a generated view (the Design Review) that correctly shows intent, implementation links, verification presence, and impact — and CI rejects the PR if any of it is broken or incomplete.* +**North Star (one sentence):** *On a small bounded context, an engineer writes specs in the ruled carrier (Markdown by default, TypeScript a lawful per-ID option), anchors the implementing code and tests with stable IDs, runs `sdp build`, gets a graph that passes the conformance + honesty checks and that an agent can read as the agent surface, and opens a generated view (the Design Review) that correctly shows intent, implementation links, verification presence, and impact — and CI rejects the PR if any of it is broken or incomplete.* --- @@ -40,7 +40,7 @@ Package: a single **`@libar-dev/software-delivery-protocol`** (DSL + types, anch **CORE (MVP):** Phase 0 — the protocol as code; TS Spec DSL; the three descriptors (`kind` · `altitude` · `readiness`); sections (shape only); stable IDs; generic anchors; `ts-morph` one-graph extractor; honest `claim` (declared / anchored; the `inferred` category is designed-in, decoded by every consumer, and **ships empty** — its first producer is the aspirational impact graph, `06` §2); core conformance + honesty checks; readiness floors (through `ready`); delivery facts (`implemented` / `has-verifier`) derived, never authored; the agent surface (reader) + `graph.json` as AI context; the Design Review / one read-only view; bidirectional spec↔test trace; determinism + `--check-clean`. The **entire trust model** ships at MVP. The delivery-process **lenses** — discipline-as-filter, release/baseline as git-tag projections (`06` §6) — come essentially **for free** off the graph + git tags; they are not separate built features and get no dedicated slice. -**Carrier addendum (post-MVP).** The CORE list above records what the MVP shipped — the TS DSL was then the only carrier. The carrier ruling (MD-18) has since ruled the Markdown carrier for all eight kinds: New spec IDs may be born Markdown-canonical once the product parser lands; pre-existing IDs and the worked example remain TS-canonical until the ruled flip (the product parser, `sdp import`, and the checkout-v1 migration). The product parser has landed — new IDs may be `.sdp.md` today — while import, migration, and the canonical-default flip remain deferred. New authoring reads the CORE list through this rule. +**Carrier addendum (post-MVP).** The CORE list above records what the MVP shipped — the TS DSL was then the only carrier. The carrier ruling (MD-18) now governs new authoring: Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option. New authoring reads the CORE list through this rule. **ASPIRATIONAL:** runtime-observation overlay (the `observed` delivery fact; runtime observations, `Build`/`Deployment`/`Observation` nodes, `nfr-violated`); runtime-composition depth (Effect `R`, Awilix wiring, Fastify trees); Gherkin surface (a carrier contender the ruling declined, MD-18); harnesses + simulation; rich projections (LikeC4/OpenAPI/JSON-LD/SHACL); rich Spec Studio with scoped intent composition; AI slices + the **MCP surface** (designed-in, deferred build) + GraphRAG; architecture-enforcement checks; a fuller impact graph; incremental builds/caching; full CLI; `--lenient` ratchet; multi-tenant/multi-repo/polyglot. diff --git a/docs/concept/DECISIONS.md b/docs/concept/DECISIONS.md index 1504a75..10077be 100644 --- a/docs/concept/DECISIONS.md +++ b/docs/concept/DECISIONS.md @@ -1,556 +1,51 @@ -# Decision diary — Libar Software Delivery Protocol (meta-design) +# Decision registry — Libar Software Delivery Protocol -> A why-focused log of hard-to-reverse decisions about **building the Protocol itself** — distinct from *in-system* -> decision records (a project's own `kind:"decision"` instances). Its job is to **recapture rationale** so choices -> stay visible and don't re-smooth into "false settledness." Entries are short and **status-tagged** -> (`ACCEPTED` / `PROPOSED` / `SUPERSEDED`). Companion to `CONTEXT.md` (repo root; formerly -> `docs/concept/ubiquitous-language.md` — the ratified glossary; the model exposition lives in `00`–`07`). -> -> **Decoding historical `base §n` references** (used by older entries below and the archived `reviews/`): -> the restructured base's old sections map to — -> §0 thesis · §1 meta-levels → `00` §2 · §2 primitive & boundary → `02` §1–§4 · §3 relations → `02` §6 · -> §4 claims & the authored/derived split → `01` + `03` §3 · §4b delivery facts → `02` §2 · -> §5 the one graph → `03` · §6 validation & honesty → `05` · §7 surfaces & projections → `06` · -> §8 delivery-process execution → `06` §6. +> The registry retains durable decision names, one-line glosses, and their carrying Specs. Historical execution and rationale live in git, plans, and the Specs themselves. ## The ratified-name registry -The name is the primary handle; the ID is the breadcrumb. **In prose, lead with meaning — write "the -typing law (MD-11)," never bare "MD-n."** (Generalizes MD-8's own footnote.) *Curation* records each -entry's standing against the ADR three-part test (hard to reverse · surprising without context · a -real trade-off — see the working discipline in `AGENTS.md`); *durable* entries become `kind:"decision"` -specs at the decision-spec fold, under the future spec id reserved here. **Fold trigger re-pointed -(2026-07-11):** originally "post-Slice-1"; slices 1–5 landed with no fold, and the executable-spec -format decision (plan 12) will rule on the very authoring carrier decision specs would be written in — -so the fold now waits for that ruling, and the decision specs are authored once, in the format that -survives. **Format ruled (2026-07-12, the carrier ruling — MD-18):** the Markdown carrier; the fold -itself rides with the self-hosting session (plan 16 §7). - -| ID | Ratified name | Curation | Future spec id | -|---|---|---|---| -| MD-1 | the executable meta-model | durable | `spec:protocol.decisions.executable-meta-model` | -| MD-2 | adopt the nouns, reject the gates | durable | `spec:protocol.decisions.adopt-the-nouns` | -| MD-4 | one primitive, named coordinates | durable | `spec:protocol.decisions.one-primitive` | -| MD-5 | the protocol naming | durable | `spec:protocol.decisions.protocol-naming` | -| MD-7 | binding, never liveness | durable | `spec:protocol.decisions.binding-not-liveness` | -| MD-8 | the generic `codeAnchor` | **folded** (Slice 2) → doc-comment on the `codeAnchor` builder (`src/model/anchors.ts`) | — | -| MD-9 | the open-questions home | folds at the fold (lives in `sections.ts`, the floor, `02` §3) | — | -| MD-10 | content-only sections | durable | `spec:protocol.decisions.content-only-sections` | -| MD-11 | the typing law | durable | `spec:protocol.decisions.typing-law` | -| MD-12 | the kind-conditional floor | durable | `spec:protocol.decisions.kind-conditional-floor` | -| MD-13 | floor-table-as-truth | folds (lives in `05` §3's Representation note + the `readiness-floor.ts` header) | — | -| MD-14 | one validation path | durable | `spec:protocol.decisions.one-validation-path` | -| MD-15 | the `.sdp.ts` extension | durable | `spec:protocol.decisions.sdp-ts-extension` | -| MD-16 | carried evidence | durable | `spec:protocol.decisions.carried-evidence` | -| MD-17 | point-per-example | durable | `spec:protocol.decisions.point-per-example` | -| MD-18 | the carrier ruling | durable | `spec:protocol.decisions.carrier-ruling` | -| MD-19 | the prose-ownership law | durable | `spec:protocol.decisions.prose-ownership` | -| MD-20 | the strict consumer-exclusion contract | durable | `spec:protocol.decisions.exclusion-contract` | -| MD-21 | the envelope-grammar ownership posture | durable | `spec:protocol.decisions.envelope-grammar-posture` | +In prose, lead with meaning and use a code only as a parenthetical lookup key. Curation records the ADR three-part test: hard to reverse, surprising without context, and a real trade-off. + +Numbering is append-only: absent identifiers such as MD-3 and MD-6 are retired or reserved historical +positions and are never reused. + +| ID | Ratified name | Curation | Gloss | Spec pointer or reservation | +|---|---|---|---|---| +| MD-1 | the executable meta-model | durable | Delivery intent conforms to a typed executable meta-model. | [Spec](../../specs/decisions/executable-meta-model.sdp.md) (`spec:decisions.executable-meta-model`) | +| MD-2 | adopt the nouns, reject the gates | durable | Shared delivery nouns do not imply workflow gates. | [Spec](../../specs/decisions/adopt-the-nouns.sdp.md) (`spec:decisions.adopt-the-nouns`) | +| MD-4 | one primitive, named coordinates | durable | One enrichable Spec carries independent coordinates. | [Spec](../../specs/decisions/one-primitive.sdp.md) (`spec:decisions.one-primitive`) | +| MD-5 | the protocol naming | durable | The product and protocol names remain stable. | [Spec](../../specs/decisions/protocol-naming.sdp.md) (`spec:decisions.protocol-naming`) | +| MD-7 | binding, never liveness | durable | Anchors state bindings, never runtime truth. | [Spec](../../specs/decisions/binding-not-liveness.sdp.md) (`spec:decisions.binding-not-liveness`) | +| MD-8 | the generic `codeAnchor` | folded | One generic binding form spans code locations. | `src/model/anchors.ts` | +| MD-9 | the open-questions home | folds | Unsettled durable questions live on their Spec. | `src/model/sections.ts`, `05` §3 | +| MD-10 | content-only sections | durable | Sections carry content while relations carry links. | [Spec](../../specs/decisions/content-only-sections.sdp.md) (`spec:decisions.content-only-sections`) | +| MD-11 | the typing law | durable | Floor-read sections have closed typed shapes. | [Spec](../../specs/decisions/typing-law.sdp.md) (`spec:decisions.typing-law`) | +| MD-12 | the kind-conditional floor | durable | Readiness evidence varies with the Spec kind. | [Spec](../../specs/decisions/kind-conditional-floor.sdp.md) (`spec:decisions.kind-conditional-floor`) | +| MD-13 | floor-table-as-truth | folds | The floor table is its code-level source of truth. | `05` §3, `src/validate/readiness-floor.ts` | +| MD-14 | one validation path | durable | Validation runs only through the derived graph. | [Spec](../../specs/decisions/one-validation-path.sdp.md) (`spec:decisions.one-validation-path`) | +| MD-15 | the `.sdp.ts` extension | durable | The extension law is re-pointed, not repealed: carrier extensions identify Specs without test-glob collisions. | [Spec](../../specs/decisions/sdp-ts-extension.sdp.md) (`spec:decisions.sdp-ts-extension`) | +| MD-16 | carried evidence | durable | Promoted evidence must carry the evidence it represents. | [Spec](../../specs/decisions/carried-evidence.sdp.md) (`spec:decisions.carried-evidence`) | +| MD-17 | point-per-example | durable | Each example is one bound point. | [Spec](../../specs/decisions/point-per-example.sdp.md) (`spec:decisions.point-per-example`) | +| MD-18 | the carrier ruling | durable | Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option. The surviving law is one canonical surface per ID, no mixing. | [Spec](../../specs/decisions/carrier-ruling.sdp.md) (`spec:decisions.carrier-ruling`) | +| MD-19 | the prose-ownership law | durable | Prose belongs to typed graph owners. | [Spec](../../specs/decisions/prose-ownership.sdp.md) (`spec:decisions.prose-ownership`) | +| MD-20 | the strict consumer-exclusion contract | durable | Consumer exclusions are explicit root-relative paths. | [Spec](../../specs/decisions/exclusion-contract.sdp.md) (`spec:decisions.exclusion-contract`) | +| MD-21 | the envelope-grammar ownership posture | durable | The Protocol owns the envelope contract, not the YAML library. | [Spec](../../specs/decisions/envelope-grammar-posture.sdp.md) (`spec:decisions.envelope-grammar-posture`) | ### Current executable decision-spec pointers -- [The plain-language references decision](../../specs/decisions/plain-language-references.sdp.md) - (`spec:decisions.plain-language-references`). -- [The concept-documents dissolution decision](../../specs/decisions/concept-docs-dissolve.sdp.md) - (`spec:decisions.concept-docs-dissolve`). - ---- - -## 2026-06-06 — Session: reframe + language base - -> **Retired entries (2026-06-07).** **MD-3** (three meta-levels; Phase 0 = the Protocol as code) is fully absorbed -> into `ubiquitous-language.md` §1 — conclusion *and* rationale — and was deleted. **MD-6** (Spec named; -> descriptor enums locked; bounded-context → projection) is canonical in the base ledger + §2; its one unique -> nugget — the **partial reversal of MD-4** — was folded into MD-4 below, and the entry deleted. - -### MD-1 — The Protocol is an executable, self-validating software-delivery meta-model [ACCEPTED · gloss refined 2026-07-11 (the plan-12 session): "executable specs alone (BDD) is unremarkable" stands as *positioning*, never as a rejection of executability — the ratified framing is that gen 1's disease was a binding invisible to the type system (dual-source + runtime matching), not executable specs; the executable half returns as a **recovered surface under the meta-model headline** (generated step contracts, the typed example space, the oracle), never a pivot] -**Decision.** We are building a *typed, executable meta-model of the software-delivery process*: teams author -delivery intent as **`Spec` instances** — the Protocol's one authored truth-primitive — and author only two -other non-truth constructs, **`Pack`** and **`anchor`**; the meta-model (code in the repo) deterministically -checks **conformance and honesty** and derives **one graph**; every other artifact is a **projection**. -The headline is an **executable, self-validating delivery protocol — not just executable specs.** -**Two permanent honesty guardrails** (these are what keep it from re-becoming the gating we rejected): -1. It defines the **conformance contract**, and its checks cover **conformance & honesty**, never - **content-quality** (design goodness = human/agent judgment) and never **workflow** (no lifecycle gates — - the lifecycle-FSM trap). -2. We claim **"deterministically validated,"** never **"provably correct."** -**Why / alternatives rejected.** "Executable specs" alone (BDD) is unremarkable; earlier delivery-process frameworks modeled the process only -*descriptively*; PM tools (Jira/Linear) have process *state* but no *meta-model*. The executable meta-model plus -conformance + honesty checks is the differentiator. Hard to reverse (everything builds on it); confirmed by the -user this session. - -### MD-2 — Governing language rubric: adopt the nouns, reject the process state-machine [ACCEPTED] -**Decision.** Use the established delivery **noun** for any concept the Protocol shares with the industry; coin/keep a -distinct word only for a real differentiator (and then it must *name the difference*). Reject process -**state-machine / lifecycle gating** vocabulary. Every term faces two tests: carries epistemic status where it -matters (authored vs derived), and is unambiguous to all three readers (typed code/CLI · coding agent · Studio -user). -**Why.** The system spans a **commercial** user-facing product (Libar Studio: Desktop/Web/CI-CD) *and* the -AI-native engine; users must not relearn delivery vocabulary. This **reverses** the prior brief's "not a PM tool / -don't adopt PM ladder names" stance (see scope note). - -### MD-4 — One authored primitive; familiar delivery nouns are named coordinates [ACCEPTED · refined & partially superseded — see note] -**Decision.** One **authored** primitive (enrich-in-place; never migrated to another artifact type), positioned by -**three descriptors**: **category of truth** (`kind`) is a *true subtype* (changes required detail + validation); -**altitude/size** and **maturity** are *positions*. Familiar nouns are **named coordinates, not separate authored -types**: Decision Record / Use Case / NFR / Contract / Model = `kind`; Epic / Feature / Story = altitude; -"Executable Spec" = an example-kind instance with a verifier. They are first-class in the **language and Studio -surfaces**, never separate authored truth-primitives. -**Why.** Preserves "one primitive" (the differentiation) while honoring "adopt the nouns" — and avoids the -combinatorial explosion of a subclass per (category × size × maturity) cell. Names deferred per method. -**Later (2026-06-06; absorbed from the since-removed MD-6).** The primitive is named **`Spec`**, and the three -descriptor enums are **locked in `ubiquitous-language.md`** (`kind` = 8 values; `altitude` = `epic → feature → -story`; `readiness` = `idea → scoped → defined → ready`, `ready` carrying a readiness floor). **Partial reversal -of the above:** a **bounded-context / domain** and a **`capability`** are **not altitudes** — `epic` is the -altitude ceiling; both are realized as **`Pack` groupings + a Capability Map projection**. Above-epic (initiative -/ theme) defers, additive later. `candidate` was dropped from readiness (an FSM-status imprint). - -### MD-5 — "Software delivery protocol" adopted for the meta layer and the system name [ACCEPTED 2026-06-07] -**Decision.** Name the meta layer a **software delivery protocol** — *"not any kind of protocol, it is software"* -(executable, a reference implementation that *is* the contract). Adopted at the **"name + meta-model noun"** -depth: "protocol" is both the **product name** (Libar Software Delivery Protocol; short form "the Protocol") and -the **noun for the meta-layer** (the meta-model *is* the conformance contract). The npm package is the single -**`@libar-dev/software-delivery-protocol`**; the CLI is **`sdp`**; the repo is `libar-dev/software-delivery-protocol`. -**Why.** A *protocol* is a **conformance contract, not a workflow** — precisely what MD-1 guardrail 1 states; -**more honest than "process,"** which carries the workflow connotation we reject. It distances the tool from -PM/ticket gating and reads cleanly alongside the commercial Studio. (External review — Gemini 3.1 Pro — -independently endorsed the framing for these reasons.) -**Resolved tensions.** "Protocol" can connote comms/wire formats, and "process" is the recognized industry noun -(MD-2 bias) — both are resolved by a **surgical split, not a blanket swap**: "process" is retained for the modeled -**software-delivery process** (the activity teams perform), for the rejected **process state-machine**, and for -**delivery-process execution**; only the meta-layer's own naming ("process meta-model" → "the protocol") -changed. This unparks the naming pass. - -### MD-7 — Honesty-sharpenings from the Codex adversarial pass: liveness, impact coverage, `ready` semantics [ACCEPTED 2026-06-07] -**Context.** An adversarial review (Codex) probed three trust signals: (1) `implemented` / `has-verifier` are -anchor-driven and assert no liveness — dead code or a skipped test can look done; (2) file-level blast-radius can -silently under-report; (3) `ready` is described as "reviewed" but the model records no review fact. -**Decision.** Resolve all three as **honesty clarifications consistent with already-settled decisions**, *not* via -the reviewer's heavier remedies: -1. **Delivery facts are binding/existence, never liveness.** Define an *enabled verifier* as a **structurally - bound, resolvable test anchor** — skip / quarantine / glob-exclusion is CI's concern, *exactly as pass/fail - is*; add "not reachable/live" to `implemented`; name the ladder `implemented → has-verifier → observed`, with - **`observed`** (aspirational) as the liveness rung. *Rejected: renaming `implemented`→`has-anchor` — it guts - the `implemented ∧ ¬ready` drift alarm, and the `claim`/`observed` ladder already answers the liveness ask.* -2. **File-level blast-radius reports its own coverage gaps.** An unanchored changed file is surfaced as an - explicit *coverage-unknown* item, never silently dropped; the MVP never claims exhaustive reach (that is the - deferred symbol-level impact graph). *Rejected: demoting blast-radius from the MVP — the honest-coverage - signal preserves it.* -3. **`ready` = the structural floor + a human's `declared` statement.** "Reviewed in context" is **intended - human practice**, not a graph fact or a checked property (checking it would be the workflow-gating §0 guardrail - 1 forbids); approval provenance, where it matters (a baseline), is **git-native** — a signed tag carries - approver + approved-at. *Rejected: an authored review/approval primitive with approver identity — it reverses - the one-primitive bet and the explicit "RBAC/approval outside the model" cut.* -**Scope.** Applied to the **ratified base** (§4b, §6, §7, §8) plus the derived `02`/`04`/`05`/`06`/`07` + -`jtbd-07` — tightenings of the base's own honesty ethos (§0 guardrail 1; §4b "readiness is stated, facts are -derived"), not reversals of direction. -**Forward note.** If multi-actor `ready` / baseline trust later becomes load-bearing, the guardrail-respecting -home for approval provenance is **signed git tags** (identity + approved-at, already in git) — not a new authored -primitive. - -### Base refinements (R-series) [R1/R2 ACCEPTED & applied 2026-06-10 (Fold-A) · R3 see below] - -> **[RETIRE-AT-FOLD]** — pure change-log by the three-part test (the entries' own words: "the code already -> conforms"); substance fully absorbed into the glossary, `01`, `03`, `04`. Kept until the decision-spec fold -> because `04` §2 cites "DECISIONS R1"/"R3" live. - -**Context.** The post-Session-1 founding-ideation review surfaced two wording imprecisions in the ratified base. -The **code already conforms** in both cases; these are language tightenings, so per the working discipline -(terminology is ratified — *flag*, don't silently edit) they were recorded as **PROPOSED** and assessed by the -post-split adversarial review as *already determined* (ratification, not deliberation). They were ratified and -applied in the **pre-grill fold session (2026-06-10)** — R1/R2 during the Fold-A base restructure, R3 in Fold-B — -rather than spending grill time on no-op-on-code wording. They are **not** model changes. -- **R1 — harmonize "anchor carries identity only" (§2) with "anchored = a human binding" (§4).** [ACCEPTED · - applied] §2 said anchors carry "identity only," while §4's `claim` table called `anchored` "a human binding" — - and a binding (it emits a `satisfies`/`verifies` edge) is more than bare identity. Unified phrasing, now in the - glossary's `anchor` entry, `01` (epistemic boundary), and `04` §2: *"an anchor says 'this code location is the - implementation/test binding for this Spec ID'; binding only, never system-truth content — never behavior, - rationale, readiness, acceptance criteria, or delivery facts."* The code already conforms (anchors hold - only `id`/`label`/target; `@ts-expect-error` proves the rest is rejected). -- **R2 — "no consumer reads source directly" → permit source *links*, forbid independent re-parsing.** [ACCEPTED · - applied] The principle (`03`/`05`/`06`) is right, but a Design Review linking to source locations *recorded in - the graph* is legitimate. Now stated in `01` P2 and `03` §4: *"Consumers may link to source locations recorded - in the graph; consumers must not independently parse source to derive their own model."* Matters when the - Slice-4 Design Review lands. -- **R3 — reconcile `04`'s `specTest` signature to binding-only.** [ACCEPTED · applied 2026-06-10 (Fold-B)] - `04` §2 documented `specTest(id, { verifies, run })` with an executing `run` callback; the implementation - (`src/model/anchors.ts`) is identity-only (`{ id, label?, verifies }`, **no `run`**). The code is the *more* - faithful one: a binding anchor carrying `run` would couple the graph binding to execution, contradicting - "the graph records that an enabled verifier *exists*, never that it ran" (§4b / MD-7). A **doc fidelity bug, - not a code gap** — `04` §2 now shows the binding-only signature matching the code. Same pattern as R1/R2 - (the code already conforms). Surfaced by the post-Session-1 adversarial review (F6). - -### Scope note — relationship to the prior plan & brief -**Adopt-the-nouns reversal (kept for the record).** An earlier brief held "the Protocol is **not** a PM tool; do **not** -adopt SAFe/PM ladder names." MD-2 **reverses** that: the commercial Studio means users must not relearn delivery -vocabulary, so the Protocol adopts the established delivery **nouns** (as projections + vocabulary) and rejects only the -process **gating** FSM. The structural decisions **D1–D6** (recorded below) **still hold**, reframed under the meta-model -(MD-1); the cleanup plan that applied the ratified language across the 18 concept/JTBD docs was **executed and -retired** (2026-06-07). The first-draft input drafts (`GLOSSARY.md`, `UBIQUITOUS_LANGUAGE_{1,2}.md`) and the language-finalization -brief have since been **deleted** (consolidated); the **sole canonical base is -`docs/concept/ubiquitous-language.md`.** - ---- - -## 2026-06-10 — Session: pre-grill folds (Fold-A · Fold-B · archive) - -> The fold session prescribed by `plans/04` §3: restructure the language base (Fold-A — the UL doc became -> the lean glossary; model exposition rehomed into `00`–`07`), ratify-and-apply the R-series (above), fold -> the already-determined items out of the grill agenda (Fold-B — the two entries below), and archive the -> review artifacts into tracked `reviews/`. The grill (`plans/03`) now opens onto a lean base and only -> genuinely-open decisions. - -### MD-8 — Generic-anchor DSL shape: one `codeAnchor` over the implementation-flavored code namespaces [ACCEPTED 2026-06-10 · FOLDED at Slice 2 — the rationale lives on the `codeAnchor` builder doc-comment (`src/model/anchors.ts`); kept as the historical record] -**Decision.** Generalize `anchorImplementation` into a **`codeAnchor`** builder (plus branded id) accepting the -implementation-flavored code namespaces — **`impl` / `api` / `component`** — so a *generic* anchor can bind any -code location (class, function, route, module) as the base requires. -**Why / alternatives rejected.** Anchors are generic *by definition* (`04` §2 — the binding is the thing, -framework- and location-neutral), and the ID grammar already parses any lowercase namespace; only the builder + -branded id are namespace-locked today. The base therefore already forces this answer — the post-split -adversarial review assessed it "resolvable now," so it is recorded here rather than spending grill time. -*Rejected:* per-namespace sibling builders (`anchorApi`, `anchorComponent`, …) — surface bloat for zero -expressive gain. -**Execution.** Rides Slice-2 anchor extraction, together with the example's missing api/route anchor -(`plans/02` H10). *(This is the item the plans' open-decision code-space called "D6" — not the legacy -structural D6 below; lead with meaning.)* - -### MD-9 — Open-questions canonical home: `intent.openQuestions` [ACCEPTED 2026-06-10] -**Decision.** Blocking open questions live in **`intent.openQuestions`** — the honesty check (no stated -`defined`/`ready` with a blocking open question) must read them there. -**Why.** The base is unambiguous (`02` §3: `intent` carries `openQuestions`, flaggable `blocking`; the `04` -worked example authors them under `intent`). The Session-1 pre-plan drifted — the implemented floor data reads -`design.openQuestions` / `decision.openQuestions`, so a doc-following author's blocking question never fires -the marquee honesty check. The fix direction is determined **regardless of how sections get typed** (the -typed-sections grill decision only shortens the predicate), so it is recorded now rather than gated on it. -**Execution.** Stays `plans/02` Wave B (H2): read from `intent.openQuestions`, update the floor data's -`authoredPaths`, flip the gated should-fail fixture active. - -## 2026-06-10 — Session: the grill (decision resolution on the lean base) - -> The fresh `grill-with-docs` session prescribed by `plans/04` §5, agenda `plans/03`: the six genuinely-open -> decisions (plans' open-decision handles D1–D4, D7, D8 — not the legacy structural D-space below), resolved -> against the lean base, ratified inline. Base edits land in `02`/`04`/`05`; terms in the glossary; Wave B -> (`plans/02`) rewritten execution-ready at the end. - -### MD-10 — `behavior` sections carry content only; linkage lives in relations [ACCEPTED 2026-06-10] -**Decision.** `behavior.rules` and `behavior.examples` hold **content, never spec refs**: rules are prose; -an examples entry is prose **or** a structured `{ given, when, then }` object (the entry matures *in place* — -prose → structured GWT). The section ⟷ kind duality rule (`02` §3) extends to `rule`/`example`: **promotion is -exclusive** — promoting an entry to a standalone `rule`/`example` spec *moves* the content out, and the only -linkage is the child's `refines`/`verifies` relation (a reverse-edge query, never an authored ref list). -**Why / alternatives rejected.** The repo had the same two fields authored three incompatible ways (refs in -`create-order`; prose + redundant flat GWT in `valid-cart`; prose/refs mixed in the docs), and the ref form -double-linked the same fact (parent's `behavior.rules: [ref(rule)]` *and* the rule's `refines(parent)`) with -nothing naming the linkage of record — latent drift, against ambiguity-is-loud (L2). Content-only applies the -already-ratified duality rule instead of inventing shape. *Rejected:* a `prose | ref` union (formalizes the -duality, every consumer branches, double-linkage stays legal); refs-only (every two-line rule costs a full -spec — the heavy-authoring loop `plans/03` §2 warns about). -**Consequences.** GWT's canonical home is **nested in the examples entry** (the flat `behavior.given/when/then` -the floor overlay reads today is Session-1 drift — reconciled in Wave B). Section-embedded referential -integrity (plan 02 H4) **dissolves for `behavior`** — there are no refs to check. The readiness floor must -count promoted children as evidence so promotion never costs a spec its earned rung (MD-12/D7, `05` §3). -**Execution.** Wave B: type `BehaviorSection` to this shape (D1), re-author the example (drop ref lists from -`create-order`; drop `valid-cart`'s redundant prose entry; nest its GWT), repoint the `example` overlay clause. - -### MD-11 — The typing law: floor-bearing sections are closed-typed; `decision.status` rejected [ACCEPTED 2026-06-10] -**Decision.** Sections get typed by a **criterion, not a list**: *every floor-bearing section* — one a -readiness-floor clause reads as evidence — gets a **closed typed shape** (no index signature). Today that is -six: `intent`, `behavior` (per MD-10), `constraints[]` (array, per `02` §1 / plan 02 H3), `model`, -`verification`, and `decision` (pulled in because the kind-aware floor, MD-12, makes it the natural evidence -of `decision`-kind specs). `design` and `ui` stay open bags — genuinely-unsettled surfaces keep breathing -(L9). The criterion outlives the list: a section becomes typed when a floor clause starts reading it. -**Why / alternatives rejected.** Closed shapes are simultaneously the honesty fix (the in-section bypass -`behavior: { "has-verifier": true }` stops typechecking — the type level closes what the H8 gated fixture -locks) and the single highest-value adoption lever (autocomplete + shape guardrails against the -heavy-authoring loop, `plans/03` §2). *Rejected:* the carried "five" list (same intent, but a list goes stale -the moment the floor moves — D7 proved it immediately); all eight (commits architecture/UI surfaces that are -not settled); none + a runtime authoring-shape validator (no guardrails, the tracer bullet keeps proving -nothing about shape). -**`decision.status` is rejected vocabulary** (glossary ledger): the adoption arc is the envelope's -`readiness` (raised → explored → written → ratified, floor-checked); replacement is `supersedes`; a rejected -path is not a truth-spec — it lives in the chosen decision's `alternatives`/`consequences`. The example's -`decision.status: "accepted"` is Session-1 drift, cleaned in Wave B. -**Execution.** Wave B types the six in `sections.ts`, drops the `constraints` record-form handling -(validators stop string-probing), re-authors the example, and flips the gated H8 fixture -`invalid-hand-authored-delivery-fact-in-section` active. - -### MD-12 — The readiness floor's evidence clause is kind-conditional at both rungs; overlays dissolve [ACCEPTED 2026-06-10] -**Decision.** The floor splits into **kind-blind structural clauses** (envelope at `idea`; `intent.outcome` + -≥1 relation at `scoped`; no blocking open questions at `defined`+; resolution clauses at `ready`) plus **one -kind-conditional evidence clause**: `scoped` = the kind's natural evidence is *present* (prose acceptable), -`defined` = the evidence is *complete* where the kind defines a stronger form (structured GWT for `example`, -machine-readable `target` for `constraint`, the written choice for `decision`). The separate overlay -mechanism is dissolved into the per-kind evidence table (`05` §3). Three bounding laws: monotonic by -construction (every `defined` cell implies its `scoped` cell), promotion-neutral (promoted forms count — -MD-10), convergence is honest (`rule`/`model` rungs converge rather than become quotas). `contract` maps to -the behavior-family row as a **documented interim** with a named deferral: when a dedicated contract section -lands, the typing law (MD-11) pulls it in and the row repoints. -**Why / alternatives rejected.** The review's top finding (F1), extended one rung down by the grill: the -plan's defined-only sketch would NOT have de-padded the example, because floor clauses are cumulative and -`scoped`'s kind-blind `rules-examples-or-constraints` clause is what `order-model` (`model.terms` counts for -nothing) and `order-lifecycle` (the `decision` section counts for nothing) were padding for. The old floor -was also non-monotonic for `constraint` (its natural evidence stopped counting between `scoped` and -`defined`) — half-delivering the P8 "kind changes required detail" promise: at the floor, `kind` only ever -*added*. *Rejected:* defined-only (leaves the padding one rung lower); dropping evidence below `ready` -(guts the honest-readiness differentiator — `defined` would stop meaning anything checkable). -**Consequences.** The canonical example de-pads — the throwaway `behavior.rules` on `order-lifecycle`, -`order-model`, and `order-latency-constraint` are deleted, and `checkout-v1.test.ts` stops asserting padding -is green. An AI author no longer learns to sprinkle `behavior.rules` onto decision records. -**Execution.** Wave B rewrites `readiness-floor.ts` as the per-kind evidence table (with D3's -table-as-single-source collapse), de-pads the example, and re-authors the H8 fixture -`invalid-defined-constraint-without-target` (which itself currently pads). +- [The plain-language references decision](../../specs/decisions/plain-language-references.sdp.md) (`spec:decisions.plain-language-references`). +- [The concept-documents dissolution decision](../../specs/decisions/concept-docs-dissolve.sdp.md) (`spec:decisions.concept-docs-dissolve`). +- [The Pack reified decision](../../specs/decisions/pack-reified.sdp.md) (`spec:decisions.pack-reified`). +- [The agent surface scripts the graph decision](../../specs/decisions/agent-surface-scripts-graph.sdp.md) (`spec:decisions.agent-surface-scripts-graph`). +- [The MCP-deferred decision](../../specs/decisions/mcp-deferred.sdp.md) (`spec:decisions.mcp-deferred`). -### MD-13 — The floor table in code is the single source of truth; the evaluator is generic [ACCEPTED 2026-06-10 · status 2026-07-11: the one-validation-path re-key (MD-14, executed) moved predicate signatures from `(spec, model)` to `(node: PrimitiveNode, index: GraphIndex)`; the table / derived clause-id union / generic evaluator all stand — `src/validate/readiness-floor.ts`] -**Decision.** `readiness-floor.ts` becomes the one home of the floor: each row carries `{ clauseId, -description, predicate }` (a named predicate from a small library, with paths as arguments where presence -*is* the check); the per-kind evidence map (MD-12) lives beside the clauses; the clause-id union is -**derived** from the table via `typeof`, never re-enumerated; the evaluator is one generic loop. Evidence -predicates take `(spec, model)` — promotion-neutrality (MD-10/MD-12) needs the authored model to see -refining children. Decorative metadata is banned: `authoredPaths` either becomes a real predicate argument -or is deleted. -**Why / alternatives rejected.** 453 lines for three checks; clause ids enumerated in four places (add a -clause → edit 3–4 spots; miss the switch and it **silently skips** — a validator that silently stops firing -is exactly what `05` §5 exists to prevent); `authoredPaths` was verified decorative. Table-as-truth also -buys doc↔code fidelity: `05` §3's tables and the data table are reviewable as mirror images. *Rejected:* -evaluator-as-truth (the doc table would mirror a switch; the sync burden survives); minimal trim (the -silent-skip failure mode survives). -**Execution.** Wave B (plan 02 H5), together with the MD-12 floor rewrite — one change, since the table -being rewritten is the table being collapsed. - -### MD-14 — One validation path, through the one graph; `AuthoredModel` retires as a public seam [ACCEPTED 2026-06-10 · EXECUTED — the extractor landed at Slice 1, the graph-validator re-key at Slice 3; `AuthoredModel` is deleted and `validateGraph` is the sole validation seam] -**Decision.** When the extractor lands, validators consume **the extractor's output** — one path: source → -extract (static reification, P5) → graph (in memory) → conformance + honesty checks; `sdp validate` = -`sdp build` + checks. `AuthoredModel` is demoted to (at most) an extractor-internal intermediate — never a -second public validation seam, never a "pre-graph lint" mode. Authoring-time feedback is owned by typed -sections + `tsc` (MD-11) and the `sdp/spec-static` lint, not by a parallel validator path. -**Why / alternatives rejected.** Sharper than the "two paths drift" worry: the two ingestion modes can -**disagree** — the Session-1 harness validates *imported, evaluated* spec objects, while the extractor -*statically reifies without executing* (`04` §1). A non-static expression evaluates to a value on import but -drops in reification, so import-path validation can pass a spec the graph doesn't hold — honesty checks -validating a phantom. The protocol's truth is what source *statically states*, not what it *evaluates to*. -This also makes the crippled-graph gap strategy's minimal slice ("extract → graph, no more," `plans/04` §2) -sufficient for self-validation. *Rejected:* a documented dual path (the disagreement above, institutionalized). -**Execution.** Slice 1 (extractor feeds the floor checks) / Slice 3 (the full gate); until then the Session-1 -harness stands in, honestly fenced (the `authored-model.ts` doc-comment already says so). - -### MD-15 — Authored Spec files carry the `.sdp.ts` extension [ACCEPTED 2026-06-10 · amended 2026-07-18 (plan 17) — re-pointed, not repealed] -**Decision.** Spec files are `/specs/**/*.sdp.ts` (packs: `*.pack.sdp.ts`). The Protocol's own compound -extension — the `.stories.tsx` pattern: tool-branded, collision-free, tooling-scopeable. The model name -`Spec` is untouched (it was always settled); only the file serialization changes. -**Amended 2026-07-18 (plan 17) — re-pointed, not repealed.** The rationale below (never `.spec.ts`; -a tool-branded, collision-free compound extension; the filename itself carrying the marker for a -colocated future) carries over to the ruled carrier: a Markdown-carrier Spec file is -`/specs/**/*.sdp.md`. `.sdp.ts` remains the extension of the TS DSL carrier while that carrier -survives — the import source and lawful per-ID option (the carrier ruling, MD-18). -**Why / alternatives rejected.** `*.spec.ts` is *the* default test glob of the JS ecosystem (Vitest: -`**/*.{test,spec}.?(c|m)[jt]s(x)`; Jest/Mocha conventions match). An adopter on runner defaults gets their -runner executing Spec-primitive files — Vitest fails files with no test suites, so first contact with the -Protocol is a baffling CI failure. This repo's own narrowed `vitest.config.ts` was the dodge that proved the -landmine. Decided now, while the rename costs nine example files and zero adopters. *Rejected:* keep + -documented exclusion (pushes a config edit onto every adopter, and IDE test explorers keep mislisting spec -files); `.spec.sdp.ts` (collision-free but verbose self-restatement); directory-convention-only (identity -lives only in the path — future colocated specs would carry no marker). -**Execution.** Wave B renames the example files and this repo's docs/globs; the extractor (Slice 1) ships -reading `*.sdp.ts` from day one. - -## 2026-06-10 — Session: Wave B execution + post-execution adversarial pass - -### MD-16 — Honesty-sharpenings from the post-Wave-B adversarial pass: promoted evidence must be carried; authoring-shape gets its runtime stand-in; `doc:` targets are an explicit deferral [ACCEPTED 2026-06-10] -**Context.** An adversarial review (Codex) of the executed Wave B challenged three honesty surfaces: -(1) the promotion-neutral floor counted *any* refining `rule`/`example` child and *any* `constrainedBy` -edge as evidence — an empty stub child or a wrong-kind edge could clear a parent's `scoped`/`defined`; -(2) MD-11's closed sections rely on TypeScript's excess-property check, which fires only on fresh -literals — a section assembled through an intermediate variable smuggles a delivery fact past `tsc`, -and nothing caught it at runtime before Slice 3; (3) the base's `decidedBy` → external `doc:` ADR -affordance (`02` §6) is unrepresentable in the DSL, making the F2 doc-note close-out read stronger -than its scope. -**Decision.** All three resolved as honesty clarifications consistent with the settled decisions: -1. **Promoted evidence counts only when the promoted spec itself carries its kind's evidence** (its - `scoped` cell in the per-kind table): a refining `rule`/`example` child must hold its - statement/entry; a `constrainedBy` edge counts only when it resolves in the model to a - `constraint`-kind spec with non-empty `constraints[]` (the section ⟷ kind twin of inline - `constraints`). Follows from MD-10 — promotion *moves content out*, so an empty child is not a - promotion but a stub. *Rejected:* requiring a minimum child **readiness** (a cross-spec readiness - gate below `ready` — the base reserves cross-spec rungs for the deferred `ready` clauses, and - readiness is the author's statement, not structural evidence); counting `rule`-kind `constrainedBy` - targets in the constraints-evidence slot (the duality twin of `constraints` is `constraint`; - additive to relax later if real usage wants it). -2. **Authoring-shape honesty (`05` §2, check 5) gets its Session-1 runtime stand-in now**: - `validateAuthoringShape` fails any spec or pack that hand-authors a delivery-fact key - (`implemented` / `has-verifier` / `observed`) at the envelope or inside any section. Not a second - validation path (MD-14): it is an MVP honesty check implemented early on the same stand-in harness - the readiness floor already rides, and it migrates onto the graph with the rest. The type-level - closure (MD-11) stays the ergonomics lever; its fresh-literal scope is now recorded for sections - exactly as F7 recorded it for the envelope, and the bypass is pinned twice (compile-time + - runtime fixtures). -3. **`doc:`-target relations are an explicit named deferral**, stated where the affordance is promised - (`02` §6, the glossary's flagged ambiguities, and doc notes in `ids.ts`/`relations.ts`): the DSL - stays `spec:`-target-only until the external-ADR need arrives. F2 stays closed *as the doc-note it - was scoped to be*; implementing a `DocId` target type now was rejected (no call site wants it, and - the shape should be decided when `doc:`/pack-targeting actually arrive). - -**Status (2026-07-11).** Point 2 executed differently in detail once the one-validation-path re-key -(MD-14) landed: the check is named **`checkAuthoringShape`** (not `validateAuthoringShape`) and lives -on the graph inside `validateGraph` (`src/validate/validators.ts`) — the stand-in harness it was born -on is deleted. It also **split**: the envelope/top-level delivery-fact rejection moved into the -extractor (`extract/reserved-property`), so the validator scans section interiors only. Points 1 and 3 -landed as written. - -## 2026-07-11 — Session: plan 12 run (the universals ratified · the carrier competition opened) - -> The session's full record — the seven ratified settlements, the carrier-competition restructure, -> the exhibit bar, sequencing, and the law rulings that needed no new entry (JS-B2.3 kept: A2 is the -> generated-union route the base already designed in, so the collision existed only under the dead -> A1; dual-source ruling deferred to the carrier ruling session; MD-15 untouched) — lives in -> **plan 12 §8**. Only one ruling passed the three-part test and enters here. - -### MD-17 — One point per example; tables are surface sugar [ACCEPTED 2026-07-11] -**Context.** The ratified example-space design (plan 12 §7, settlements 7–9) has an `example`-kind -child bind a **point** in its parent's typed example space, and the coverage machinery counts each -example as the **witness** of one outcome class. A consumer-corpus reread of gen-1 usage in a -production project surfaced a colliding house style: the dominant authoring pattern there (93 of 132 -executable features) is **one scenario asserting a whole truth-table** — many cases, one authored -unit. Forcing one authored spec per case would make a 10-row table ten sibling files and fight how -authors demonstrably think. -**Decision.** The graph model stays **one bound point per `example`** — the concreteness law and -one-witness-per-class coverage semantics stay unconditional. A table of cases is **authoring-surface -sugar**: a carrier may offer table syntax that **statically expands to N sibling examples at -extraction** (the same move the import mapping already proved for Scenario Outline `Examples` rows), -and renderers may project a sibling set back as a table — so authors keep the compact view at both -ends while the graph never holds a multi-point example. -**Why / alternatives rejected.** *An example may bind a point set*: keeps file counts down on every -surface but makes the concreteness law and witness semantics conditional everywhere — the machinery -pays forever for an authoring convenience one layer can absorb. *Strictly one point, no sugar*: -purest, but contradicts the observed house style and taxes the gen-1 migration corpus. The chosen -shape repeats the base's settled pattern — the model keeps the honest shape; surface layers translate -both directions statically (natural-language step reading is the renderer's job; table authoring is -the carrier's expansion sugar). - -## 2026-07-12 — Session: plan 16 run (the carrier ruled · prose ownership ruled) - -> The session's full record — the evidence table, the grounds, the re-dismissals by name, the -> doc-repair bill, and the scheduled sessions — lives in **plan 16** (the session record of -> record). The flagged terms ***carrier*** and ***notation*** ratified into `CONTEXT.md` at this -> session. Two rulings passed the three-part test and enter here. - -### MD-18 — The carrier ruling: Markdown, all eight kinds; the TS DSL becomes import source + per-ID option [ACCEPTED 2026-07-12 · transition clause amended 2026-07-18 (plan 17)] -**Context.** The carrier competition (plan 14) closed with four evidence PRs: two full exhibits -(F2 Markdown, C2 own grammar) and two honest concessions (Gherkin extension/fork; typed markup). -The docket required ruling the carrier, the kind-partition question, and the TS DSL's long-term -role — evidence-vs-evidence on the recorded scorecards, never by preference. -**Decision.** The **Markdown carrier (F2) is the authoring carrier for all eight `kind` values**: -an authored `Spec` document is an `.sdp.md` file — YAML-frontmatter envelope, free prose body, -the owned notation in fenced blocks — statically extracted into the one graph. **Transition -clause, amended 2026-07-18 (plan 17):** New spec IDs may be born Markdown-canonical once the -product parser lands; pre-existing IDs and the worked example remain TS-canonical until the -ruled flip (the product parser, `sdp import`, and the checkout-v1 migration). After the flip -the TS DSL survives as the import source and as a lawful per-ID option via the canonical-surface -config (`04` §1) — the surviving law is **one canonical surface per ID, no mixing**, never -TS-as-sole-surface. -**Why / alternatives rejected.** *C2 own grammar*: strongest differentiation and diff/merge, but -its own scorecard's ownership row is decisive — parser, formatter, highlighting, rendering, -editor integrations, LSP owned forever, plus the agent grammar-context tax and no rendered page -without our tooling. *Kind-partitioned dual carrier* (first-class per the docket, not a -fallback): declined on the exhibit's own evidence — the structure-heavy `model` port was -field-exact in Markdown, so a permanent two-surface partition buys nothing it can show; the -per-ID config stays designed-in, retained not exercised. *Retiring the TS DSL outright*: burns a -designed-in escape hatch for no honesty gain — the gen-1 disease (MD-1's gloss) was dual-source -binding invisible to the type system, which the per-ID law already prevents. Hard to reverse: -every authored spec, the parser, `sdp import`, and the doc corpus build on the ruled carrier. - -### MD-19 — The prose-ownership law: owned prose in the graph, on typed owners [ACCEPTED 2026-07-12] -**Context.** The F2 exhibit counted four prose paragraphs its spike deliberately dropped at the -graph seam and named three MD-10-compatible homes (heading-path-keyed content section · -description on the owning typed section · file-only with a pointer). A document carrier winning -made this ruling due, per the docket. -**Decision.** Free prose enters the graph as **description values on typed owners** — the owning -typed section, or the `Spec` itself (a spec-level narrative slot; epic-altitude navigational -prose lives there beside `refines`). No parallel heading-keyed store; no file-only pointer. The -deterministic ownership rule for edge text (before the first heading, between sections, under -unrecognized headings) is a first-class deliverable of the surface-design session; until it -lands, unowned prose is refused loudly (ambiguity-is-loud), never silently dropped. -**Why / alternatives rejected.** *File-only + pointer*: consumers may link to source but never -re-parse it (P2) — a pointer forces every prose-needing projection to re-parse or omit; dead on -the graph law. *Heading-path keys*: gen-1 delivery experience (reviewed direction-level; no gen-1 -artifact cited durably) shows heading structure is what churns hardest across readiness -promotions — sections born, renamed, killed — so heading-keyed content breaks identity exactly -where enrich-in-place lives; and prose without a typed owner is where transcription bloat grew. -Owned descriptions are MD-10-clean (content, never refs) and MD-11-aware: they land inside -already-closed shapes, and any prose slot a floor clause ever reads gets pulled into the closed -type as the typing law demands. Trade-off accepted: the ownership rule is real design work — paid -once, at the surface-design session, instead of forever in an informal transfer checklist. - -## 2026-07-18 — Session: plan 17 execution (the self-hosting phase's pre-Gate-4 docket close) - -> The phase's full record — the corpus, the executable loop, the four owner gates, and the -> docket — lives in **plan 17** and its execution branch. Plan 17 §3 flagged two rulings as -> likely diary-worthy; both passed the three-part test at the pre-Gate-4 disposition and enter -> here. The remaining §3 rulings stay in the corpus specs, the pinned tests, and the plan's -> running log by deliberate omission. - -### MD-20 — The strict consumer-exclusion contract: root-relative POSIX path-prefix, never basename-anywhere [ACCEPTED 2026-07-18] -**Context.** Self-hosting forced the configurable extraction exclude the discovery code had -deferred to external adoption: a root build must keep `explorations/` and `examples/` out of the -Protocol's own graph. The fixed tooling excludes match basename-of-directory; the consumer rule -was the open question. -**Decision.** Consumer excludes (`--exclude` on `build`/`validate`/`view`; -`ExtractOptions.exclude`) are strict, case-sensitive, root-relative POSIX path-prefixes — -rejecting empty, `.`, leading `./`, terminal `/`, absolute, `..` segments, empty internal -segments, and backslashes; deduplicated; nonexistent and file prefixes allowed; unioned with -(never replacing) the fixed tooling excludes, which stay basename-of-directory. -**Why / alternatives rejected.** *Basename-anywhere, for consistency with the tooling excludes*: -convenient, but it silently skips a future nested `src/examples/` — consumer excludes are scope -decisions, and scope deserves paths, not names. Hard to reverse: a public CLI/options surface -consumers script against. Trade-off accepted: consumers must be explicit; precision beats -convenience. - -### MD-21 — The envelope-grammar ownership posture: the contract is ours, the YAML library is a swappable representation (exact `yaml@2.9.0`) [ACCEPTED 2026-07-18] -**Context.** The Markdown envelope needed a YAML boundary, and the library's own current -documentation no longer promises that parsing never throws — a permissive parse would make the -envelope's meaning a library behavior rather than a Protocol-owned grammar. -**Decision.** The Protocol owns the envelope grammar as an authored contract spec plus a bounded -parser policy — failsafe schema, exactly one nonempty document, manual AST/CST mapping (never -`toJS()` before policy validation), every warning/directive/tag/anchor/alias/merge/complex -key/non-string scalar refused, byte/depth/node/scalar/finding caps — and pins the representation -exactly: `yaml@2.9.0`, no semver range; the library stays a swappable representation behind the -owned contract. -**Why / alternatives rejected.** *Permissive parsing + a semver range*: broader YAML acceptance -and free patch upgrades, but the grammar's meaning would drift with the library and adversarial -input would meet an unbounded parser. *An own YAML parser*: total ownership, but it re-opens the -dismissed own-grammar economics (the carrier ruling, MD-18). Hard to reverse: the corpus is -authored against the owned contract and consumers see its finding IDs. Trade-off accepted: -corpus-scoped strictness — full YAML/CommonMark parity is deliberately not claimed. - -## Structural-decision shorthand (D1–D6) [ACCEPTED · relocated here when the cleanup plan was retired, 2026-06-07] - -> These six labels come from the original structural-decisions pass. Their *content* is canonical in the -> design docs (`00`–`07` — since the Fold-A restructure rehomed the base's model exposition there); this table -> is kept for permanent traceability so any historical `(Dn)` reference still resolves now that the -> single-use cleanup plan (which previously held it) is gone. **Do not confuse this legacy D-space with the -> open-decision handles D1–D8 used by `plans/02`/`plans/03`** — those name the post-Session-1 grill agenda -> (typed sections, prose-vs-ref, floor collapse, …), a different code-space; in prose, lead with meaning. -> **[KEEP for traceability]** — at the decision-spec fold, **D3** (Pack reified), **D5** (the agent surface -> scripts the graph), and **D6** (the MCP-deferred *no*) become decision specs of their own; the table -> retires when the artifacts citing `(Dn)` do. +## Structural-decision shorthand (D1–D6) | Label | One line | Canonical in | |---|---|---| -| **D1** | readiness (design maturity, authored) is separate from delivery facts (derived) | `02` §2 | -| **D2** | `02` carries explicit typed **sections**, trimmed to essence (branded-ID strings; open `model` list) | `02` §3 | -| **D3** | `Pack` is a reified grouping/aggregate (not folded into `Spec`); membership single-sourced on a manifest | `02` §4 | -| **D4** | **Design Review** is the flagship curated projection — the context where a human decides to state `ready` (sharpened by MD-7; the floor is checked, the review is human practice) | `06` §5 | -| **D5** | the **agent surface** = a visible typed graph the agent *scripts* (no verb wall); `reader` = thin loader | `06` §3 | -| **D6** | the **MCP surface** = integration for user-facing apps (designed-in, deferred build, shape TBD) | `06` §7 | - -## Measured evidence — figures behind the generalized doc prose [recorded 2026-06-07] - -> The concept docs deliberately carry *principle-level* wording, not point figures (figures age and read as -> false precision). The measurements that motivated those principles are preserved here as evidence. - -| Figure | Context | Generalized in the docs as | -|---|---|---| -| **~⅕ tokens** | a multi-probe agent session ran at ~⅕ the tokens of a grep/verb-API equivalent (data kept in-process, only conclusions returned) | `06` §3/§10, `jtbd-05` JS-E2 → "a measured context-efficiency win" | -| **< ~50 specs** | full-rebuild-per-run is comfortable below ~50 specs | `00` / `05` / `07` → "MVP scale" | -| **~10k+ nodes** | a property-graph DB is deferred until traversal pain (~10k+ nodes) | `03` / `07` → "until measured traversal pain" | -| **single-digit to ~25%** | a curated graph is a small selection of the mechanical (impact-graph) firehose | `06` §2, `jtbd-07` JS-G3 → "a deliberately small curated selection" | +| **D1** | readiness is separate from delivery facts | `02` §2 | +| **D2** | sections are typed to their evidence role | `02` §3 | +| **D3** | Pack is a reified grouping/aggregate | [Pack reified decision](../../specs/decisions/pack-reified.sdp.md) (`spec:decisions.pack-reified`) | +| **D4** | Design Review is the flagship curated projection | `06` §5 | +| **D5** | the agent surface is a visible graph the agent scripts | [Agent surface scripts the graph decision](../../specs/decisions/agent-surface-scripts-graph.sdp.md) (`spec:decisions.agent-surface-scripts-graph`) | +| **D6** | MCP integration is designed-in and deferred | [MCP-deferred decision](../../specs/decisions/mcp-deferred.sdp.md) (`spec:decisions.mcp-deferred`) | diff --git a/docs/concept/README.md b/docs/concept/README.md index 3adc1ac..72d509a 100644 --- a/docs/concept/README.md +++ b/docs/concept/README.md @@ -39,7 +39,7 @@ Every load-bearing claim is named as one or the other, on purpose — so a Repre | 01 | [Founding Principles & Invariants](./01-founding-principles-and-invariants.md) | The load-bearing laws, each tagged Principle/Representation and CORE/ASPIRATIONAL. Git-as-event-log. The `claim` epistemics. | | 02 | [Core Model](./02-core-model.md) | The `Spec` primitive, the three descriptors (`kind` · `altitude` · `readiness`), sections, delivery facts, stable IDs, relations. | | 03 | [The One Graph](./03-the-one-graph.md) | Derivation, determinism, the `claim` taxonomy, regenerability, git as the event log, the no-second-store rule. | -| 04 | [Authoring & Binding](./04-authoring-and-binding.md) | The two ruled authoring carriers — Markdown (`.sdp.md`, canonical for new IDs) and the TypeScript DSL (`.sdp.ts`, the worked example's carrier until the ruled flip) — plus generic source anchors (framework-neutral). Harnesses named but deferred; the carrier competition is ruled (MD-18). | +| 04 | [Authoring & Binding](./04-authoring-and-binding.md) | The two ruled authoring carriers — Markdown (`.sdp.md`, the default) and the TypeScript DSL (`.sdp.ts`, import source and lawful per-ID option) — plus generic source anchors (framework-neutral). Packs remain TS until a Pack syntax ruling; harnesses named but deferred; the carrier competition is ruled (MD-18). | | 05 | [Validation & Honesty](./05-validation-and-honesty.md) | Conformance + honesty checks and readiness floors; the MVP subset sharply separated from aspirational tiers. | | 06 | [Consumers & Projections](./06-consumers-and-projections.md) | MVP: the agent surface (a typed graph the agent scripts) + Design Review + reader; edits via intent→agent→git (no patch loop). Two surfaces (curated graph vs impact graph). Aspirational: Studio, exports, MCP surface. | | 07 | [MVP Roadmap & Open Questions](./07-mvp-roadmap-and-open-questions.md) | The vertical slice, the CORE/ASPIRATIONAL map, the cut list with rationale, and the residual open questions. | @@ -73,4 +73,4 @@ The MVP is a tight vertical slice on **one** bounded context (Order Management, - one generated read-only view, - bidirectional spec↔test trace. -The write-path is simply **edit the canonical carrier + git** — no patch loop. Under the interim carrier rule (the carrier ruling, MD-18; transition clause amended by plan 17): new spec IDs may be born Markdown-canonical once the product parser lands; pre-existing IDs and the worked example remain TS-canonical until the ruled flip (the product parser, `sdp import`, and the checkout-v1 migration). The product parser has landed, so new IDs may be `.sdp.md` today; `sdp import`, the checkout-v1 migration, and the canonical-default flip are deferred. Everything else is aspirational and labelled as such, with the rationale for cutting it in `07`. +The write-path is simply **edit the canonical carrier + git** — no patch loop. Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option (the carrier ruling, MD-18). Everything else is aspirational and labelled as such, with the rationale for cutting it in `07`. diff --git a/eslint.config.js b/eslint.config.js index a10456f..3aa34e2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,6 +24,7 @@ export default tseslint.config( ".sisyphus/evidence/**", "node_modules/**", "explorations/**", + "test/fixtures/import/parity/**", ], }, js.configs.recommended, @@ -50,7 +51,7 @@ export default tseslint.config( // gate leg. Typecheck runs after that generation and checks this test's contract types; lint // keeps all other rules enabled without making the required lint-before-generation order depend // on ignored derived output. - files: ["test/self-hosting-duplicate-ids.test.ts"], + files: ["test/self-hosting-duplicate-ids.test.ts", "test/self-hosting-sdp-import.test.ts"], rules: { "@typescript-eslint/no-unsafe-argument": "off", "@typescript-eslint/no-unsafe-assignment": "off", diff --git a/examples/checkout-v1/README.md b/examples/checkout-v1/README.md index 1181d4a..4fb3e5d 100644 --- a/examples/checkout-v1/README.md +++ b/examples/checkout-v1/README.md @@ -2,10 +2,10 @@ The MVP bounded context: **Order Management**, modeled as `pack:checkout-v1` — eleven `Spec`s, one `Pack`, five anchors. It exists to prove the loop end-to-end on one small, honest slice: author -delivery intent as typed code, bind the implementing code, the test, and the oracle with anchors, +delivery intent in its canonical carrier, bind the implementing code, the test, and the oracle with anchors, derive **the one graph**, generate the step and space contracts off it, run the conformance + honesty checks, execute the bound example, and read the generated Design Review. It is also the -tracer bullet: if this example stops typechecking or extracting, the DSL or the extractor is +tracer bullet: if this example stops extracting or validating, the carrier or the extractor is wrong — not the example. This walkthrough shows what is here and how to watch the trust model react. The concepts live in @@ -15,67 +15,53 @@ pointed at. ## The layout -- **`specs/`** — the **authored model**: one `Spec` per `*.sdp.ts` file, plus the pack manifest - (`checkout.pack.sdp.ts`). The familiar delivery nouns appear as **named coordinates on the one - primitive**, never separate types — all eight `kind`s are on disk: a `behavior` epic - (`order-management`), a `workflow` feature (`order-placement-flow`), a `behavior` feature - (`create-order`), two `example` stories (`valid-cart`, `invalid-cart`), a `contract` story - (`api-contract`), two `rule`s, one `constraint` (the latency NFR), one `model` (the domain - vocabulary), and one `decision` record. The `create-order` parent owns the **example space** — - the typed step vocabulary its steps declare (`{n:number}` · - `{availability:"in stock"|"out of stock"}`) — and its two `example` children each bind a - **bound point** in that space: `valid-cart` binds every slot it uses (`{n: 2}` … `{total: 100}`), - `invalid-cart` binds the partial point `{n: 0}` — partial is honest, because an unused step - binds nothing and fails nothing. Every spec states its own `readiness`, and the stated rungs - span the ladder (`idea` · `scoped` · `defined` · `ready`); the checks only verify the stated +- **`specs/`** contains the **authored model**. Each `Spec` is now one `*.sdp.md` file. The Pack + manifest remains `checkout.pack.sdp.ts`: Packs remain TS until a Pack syntax ruling. + + All eight `kind`s appear here as named coordinates on one primitive. The `create-order` parent + owns the example space, and its child examples bind full or partial points in that space. + + Every Spec states its `readiness`, from `idea` through `ready`. Checks verify that the stated rung is structurally earned. -- **`src/`** — the implementation, carrying three **anchors**: `impl:orders.create-order-use-case` - and `impl:orders.order-total` on the use-case file (anchors bind per spec, never per file) and - `api:orders.post` on the route. An anchor is a binding only — "this code location is the - implementation binding for this Spec ID" — never intent; each yields a `satisfies` edge with - `claim: "anchored"`. -- **`test/`** — the executable half, on three files. The bound test - (`create-order.valid-cart.test.ts`) carries the **test anchor** (`specTest`) that binds it to - `spec:orders.create-order.valid-cart` — the binding that makes the valid-cart example an - **enabled verifier** and confers the derived `has-verifier` delivery fact — and binds the - _generated_ **step contract** with `bindExample` (the `/vitest` adapter): handlers keyed by the - literal step text, parameter values flowing from the spec. The oracle - (`create-order.oracle.ts`) is the authored `expected()` semantics over the parent's example - space, typed against the generated **space contract** on both sides — Conditions in, the - Outcome union out, `unspecified` a first-class answer — with a **`specOracle` anchor** - (`oracle:orders.create-order`) recording only that the oracle _exists_ (a `models` edge, - `claim: "anchored"`); what it _says_ is implementation-side authored code, never extracted. - `drift-pins.ts` pins eight drift cases as `@ts-expect-error` tripwires — compile-time - regression tests for the drift alarm. -- **`generated/`** — the derived artifacts (untracked; the walk below produces them): the one - graph (`graph.json`), the contracts (a step contract per `example` spec + the per-parent space - contract), and the Design Review — all regenerable, never edited. + +- **`src/`** holds three implementation anchors. Anchors bind a code location to a Spec ID, never + intent, so their `satisfies` edges carry `claim: "anchored"`. + +- **`test/`** holds the executable half. The valid-cart test anchor makes that example an enabled + verifier, while generated step and space contracts keep the test and oracle aligned with the + authored example space. + +- **`generated/`** is untracked output from the walk: `graph.json`, contracts, and the Design + Review. It is regenerable and never edited by hand. ## The walk -Build the CLI once from the repo root, then run the pipeline (each command subsumes the previous -stage): +The checkout Markdown surface was produced by `sdp import` during the migration. + +`sdp import` remains the verb for any future per-ID TypeScript-to-Markdown move. Specs default to +Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a +lawful per-ID option. + +Build the CLI once from the repo root, then run the pipeline. Each command includes the stage +before it: ```sh npm run build -node ./dist/cli/sdp.js build examples/checkout-v1 # extract → generated/graph.json + contracts -node ./dist/cli/sdp.js validate examples/checkout-v1 # build + conformance & honesty checks -node ./dist/cli/sdp.js view examples/checkout-v1 # validate + the Design Review +node ./dist/cli/sdp.js build examples/checkout-v1 # extract, graph, and contracts +node ./dist/cli/sdp.js validate examples/checkout-v1 # build, conformance, and honesty checks +node ./dist/cli/sdp.js view examples/checkout-v1 # validate and the Design Review ``` -`build` prints the extraction summary and writes the one graph — flat nodes and edges, every one -carrying its `claim` (`declared` / `anchored` / `inferred`, never collapsed): +`build` prints an extraction summary and writes the one graph. Its flat nodes and edges retain +their `claim`, `declared`, `anchored`, or `inferred`, without collapsing them: ``` 11 specs · 1 packs · 5 anchors → 17 nodes · 32 edges (0 errors, 0 warnings) ``` -Beside the graph it writes `generated/contracts/` (3 modules): a **step contract** per `example` -spec — the union of that example's literal step strings and their typed parameters — and the -per-parent **space contract** — the typed dimensions of the example space, every child's bound -point, and the Outcome union derived from the parent's Then vocabulary. All three are derived -from the graph, keyed by spec ID, importable _because_ they are projections — never the authored -spec module. +Beside the graph it writes three modules in `generated/contracts/`: a step contract for each +`example` Spec and one parent space contract. They are graph projections, keyed by Spec ID, never +authored modules. The executable half runs off those contracts: @@ -84,91 +70,70 @@ npx vitest --run examples/checkout-v1/test # the bound example passes npm run typecheck:examples # the drift pins hold ``` -Because the test binds the _generated_ valid-cart contract, the spec drives the assertion: -`expect(world.order?.total).toBe(params.total)` asserts the spec's authored `100` — never a value -the test states. Editing the spec's value reddens the test with zero test edits, and a spec-side -step rename is a `tsc` error naming the stale handler key (both tripped below). The oracle -compiles against the space on both sides: reading a slot the space no longer declares, or -claiming an outcome the specs never stated, is a `tsc` error — while outcome _faithfulness_ stays -human-reviewed, by law. +The generated valid-cart contract drives the assertion. Changing a Markdown value or step can fail +the test or reject a stale handler key without changing the test. + +The oracle is typed against the same space, while outcome faithfulness stays human-reviewed. -`validate` runs the checks over that graph — one validation path — and reports **0 errors and -exactly 1 warning**: +`validate` runs one validation path over the graph. It reports **0 errors and exactly 1 warning**: ``` -specs/orders/create-order-invalid-cart.sdp.ts — [warning] conformance/verifies-linkage — +specs/orders/create-order-invalid-cart.sdp.md — [warning] conformance/verifies-linkage — Example "spec:orders.create-order.invalid-cart" declares verifies → "spec:orders.create-order" -but is not an enabled verifier — no test anchor binds it … +but is not an enabled verifier — no test anchor binds it, so the spec↔test trace is incomplete +and it confers no has-verifier. +validate: 0 errors · 1 warnings (conformance + honesty over the one graph) ``` -The warning is **deliberately kept**. `invalid-cart` declares that it verifies its parent, but no -test anchor binds it yet — the spec↔test trace is incomplete, and the graph says so instead of -pretending. This is the honesty posture in one line: a surfaced absence is informative, never a -gate (the exit code stays 0). +The warning is deliberate. `invalid-cart` declares that it verifies its parent, but no test anchor +binds it. The graph reports the incomplete trace, and the exit code stays 0 because this absence is +informative rather than a gate. -`view` regenerates `generated/design-review/` wholesale — an index plus one page per spec and -pack (13 pages), all pure projections of the graph. Open +`view` regenerates `generated/design-review/`: an index and one page per Spec and Pack. Open `generated/design-review/spec/orders.create-order.md` and look for: -- **binding language, never liveness** — "Implementation binding: present · Verifier binding: - present · Runtime observation: not tracked"; -- **the example space, rendered** — the parent's Behavior section lists the typed step vocabulary - its children bind points in; each child page renders its bound point (`{n: 2}` … - `{total: 100}`; `{n: 0}` on the partial one); -- **`claim` cues** — the implementation anchors' `satisfies` lines are `[anchored]` while every - example's own `verifies` is `[declared]`; the enabled verifier (`valid-cart`) is distinguished - from the unenabled one (`invalid-cart`); the test anchor's `[anchored]` verifier line renders - on the valid-cart page; -- **stated vs derived readiness** — the rungs vary honestly: most specs state `defined` while - structurally clearing `ready` (stating less than you clear is the honest direction — plain - header information, no banner); `valid-cart` states `ready` and earns it through its test - binding; the placement flow states `scoped` because flows are scoped-rung evidence only; the - API contract is parked at `idea` with its blocking open question recorded in the spec; -- **Relations & impact (one hop)** — the relation list read as the blast radius of changing this - spec. - -Determinism is checkable, not promised: `--check-clean` on any command runs the pipeline twice -independently and fails on a single divergent byte — across the graph, every contract module, and -the review — and `npm run check:example` gates CI on exactly this over this example. Deleting -`generated/` and rerunning reproduces the same bytes, and so does the pipeline run from a copy at -a different absolute path — both pinned in the test suite, which CI also gates. +- **binding, never liveness**: implementation and verifier bindings are present, while runtime + observation is not tracked. + +- **the rendered example space**: each child page shows its full or partial bound point. + +- **`claim` cues**: implementation bindings are `[anchored]`; declared example verification stays + `[declared]`; valid-cart is enabled and invalid-cart is not. + +- **stated and derived readiness**: the rungs remain honest, including valid-cart at `ready` and + the API contract at `idea` with its blocking open question. + +- **relations and one-hop impact**: the relation list is the blast radius of changing this Spec. + +`--check-clean` runs the pipeline twice and rejects any divergent byte across the graph, contracts, +and review. `npm run check:example` gates that determinism. Delete `generated/` and rerun to +reproduce the same bytes. ## Break it on purpose -The fastest way to understand the checks is to trip them. Each experiment is one edit; revert -with `git checkout -- examples/checkout-v1` afterwards (the next `build` regenerates -`generated/`). - -- **Dangle a reference.** In `specs/orders/create-order.sdp.ts`, misspell the `refines` target - (e.g. `spec:orders.order-managment`). `validate` exits 1 with - `conformance/referential-integrity` — and suggests the id you meant. -- **State readiness you haven't earned.** Add a blocking open question to `create-order`'s - intent — `openQuestions: [{ question: "Do guest carts create orders?", blocking: true }]` — - while it states `defined`. `validate` exits 1 with `honesty/readiness-floor`, naming the - failing clause (`no-blocking-open-questions`): readiness is stated by the author, checked - against the floor. -- **Unbind the test.** Delete the `specTest` anchor from - `test/orders/create-order.valid-cart.test.ts`. The summary line drops to - `4 anchors → 16 nodes · 31 edges`, the valid-cart example stops being an enabled verifier, the - parent loses its verifier binding, and two more warnings appear: a second - `conformance/verifies-linkage` (valid-cart's own declared `verifies` no longer confers - anything) and `honesty/gaps` (valid-cart states `ready` with no resolving verifier) — still - exit 0, because a missing verifier is a surfaced gap, never a gate. -- **Hand-author a delivery fact.** Add `"has-verifier": true` inside any section of a spec. The - closed section types reject it (`npm run typecheck:examples` fails), and even smuggled past the - types it fails `validate` with `honesty/authoring-shape` — delivery facts are derived, never - authored. -- **Drift the authored value.** In `specs/orders/create-order-valid-cart.sdp.ts`, change - `{total: 100}` to `{total: 120}` and rerun `build`. The bound test fails with zero test edits — - `at step: Then an order is created with total 120` · `expected 100 to be 120` — because the - assertion reads `params.total` from the regenerated contract: the spec's value is the truth the - test enforces, and the failure speaks spec language. (Rename the step instead — in the parent's - vocabulary and the child's use — and `tsc` rejects the test's stale handler key by name.) -- **Unbind a slot.** In the same spec, change `{n: 2}` to `{n}`. The **concreteness law** fires: - an unbound slot in a _used_ step caps the example below `defined`, and valid-cart states - `ready` — so `validate` exits 1 with `honesty/readiness-floor` (failing clause - `kind-evidence-complete`), and the contracts stage withholds the non-concrete example's step - contract (2 modules, not 3). +The fastest way to understand the checks is to trip them. Each experiment edits a `.sdp.md` Spec. +Revert the edit afterwards. The next `build` regenerates `generated/`. + +- **Use an unknown heading.** In `specs/orders/order-total-rule.sdp.md`, change `## Rule` to + `## Rul`. `validate` exits 1 with `extract/unrecognized-heading` and suggests `Rule`. + +- **Dangle a reference.** In `specs/orders/create-order.sdp.md`, misspell the frontmatter + `refines` target, such as `spec:orders.order-managment`. `validate` exits 1 with + `conformance/referential-integrity` and suggests the intended ID. + +- **State readiness you have not earned.** In `specs/orders/create-order.sdp.md`, add a blocking + open question under `## Intent` while the Spec states `defined`. + + `validate` exits 1 with `honesty/readiness-floor` and the `no-blocking-open-questions` clause. + +- **Drift an authored value.** In `specs/orders/create-order-valid-cart.sdp.md`, change + `{total: 100}` to `{total: 120}`, run `build`, then run the bound test. + + It fails without a test edit because the generated contract supplies `params.total`. + +- **Unbind a slot.** In the same Markdown Spec, change `{n: 2}` to `{n}`. `validate` exits 1 with + `honesty/readiness-floor`, citing `kind-evidence-complete`, and contract generation withholds + that example's step contract. ## Where the concepts live diff --git a/examples/checkout-v1/specs/decisions/order-lifecycle.sdp.md b/examples/checkout-v1/specs/decisions/order-lifecycle.sdp.md new file mode 100644 index 0000000..918ebc2 --- /dev/null +++ b/examples/checkout-v1/specs/decisions/order-lifecycle.sdp.md @@ -0,0 +1,21 @@ +--- +id: spec:decisions.order-lifecycle +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:orders.create-order +--- +# Order lifecycle keeps validation before creation + +## Intent + +- outcome: Decide when checkout-v1 may create an order. +- value: The authored example has one stable lifecycle rule for success and rejection paths. + +## Decision + +- decision: Create orders only after cart validation confirms non-empty input and sufficient inventory. +- rationale: The valid-cart and invalid-cart examples need one consistent gate. +- rationale: Rejecting before persistence keeps the tracer bullet small and internally consistent. +- consequence: Rejected carts never create partial orders. diff --git a/examples/checkout-v1/specs/decisions/order-lifecycle.sdp.ts b/examples/checkout-v1/specs/decisions/order-lifecycle.sdp.ts deleted file mode 100644 index 432db5d..0000000 --- a/examples/checkout-v1/specs/decisions/order-lifecycle.sdp.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { refines, spec, specId } from "@libar-dev/software-delivery-protocol"; - -export const orderLifecycleDecisionSpec = spec({ - id: specId("spec:decisions.order-lifecycle"), - title: "Order lifecycle keeps validation before creation", - kind: "decision", - altitude: "feature", - readiness: "defined", - intent: { - outcome: "Decide when checkout-v1 may create an order.", - value: "The authored example has one stable lifecycle rule for success and rejection paths.", - }, - decision: { - decision: - "Create orders only after cart validation confirms non-empty input and sufficient inventory.", - rationale: [ - "The valid-cart and invalid-cart examples need one consistent gate.", - "Rejecting before persistence keeps the tracer bullet small and internally consistent.", - ], - consequences: ["Rejected carts never create partial orders."], - }, - relations: [refines(specId("spec:orders.create-order"))], -}); diff --git a/examples/checkout-v1/specs/orders/create-order-api-contract.sdp.md b/examples/checkout-v1/specs/orders/create-order-api-contract.sdp.md new file mode 100644 index 0000000..c3ed525 --- /dev/null +++ b/examples/checkout-v1/specs/orders/create-order-api-contract.sdp.md @@ -0,0 +1,17 @@ +--- +id: spec:orders.create-order.api-contract +kind: contract +altitude: story +readiness: idea +relations: + refines: spec:orders.create-order +--- +# Create-order API contract + +## Intent + +- outcome: Pin the request and response shapes of POST /orders. + +### Open questions + +- [blocking] Does the response carry the inventory-snapshot version the order was validated against? diff --git a/examples/checkout-v1/specs/orders/create-order-api-contract.sdp.ts b/examples/checkout-v1/specs/orders/create-order-api-contract.sdp.ts deleted file mode 100644 index 4d1a9e3..0000000 --- a/examples/checkout-v1/specs/orders/create-order-api-contract.sdp.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { refines, spec, specId } from "@libar-dev/software-delivery-protocol"; - -export const createOrderApiContractSpec = spec({ - id: specId("spec:orders.create-order.api-contract"), - title: "Create-order API contract", - kind: "contract", - altitude: "story", - readiness: "idea", - // Parked at idea: the blocking open question would fail the defined floor - // (no-blocking-open-questions), and the contract carries no evidence yet — an honest idea-rung - // spec, stated low and checked nowhere above its rung. - intent: { - outcome: "Pin the request and response shapes of POST /orders.", - openQuestions: [ - { - question: - "Does the response carry the inventory-snapshot version the order was validated against?", - blocking: true, - }, - ], - }, - relations: [refines(specId("spec:orders.create-order"))], -}); diff --git a/examples/checkout-v1/specs/orders/create-order-invalid-cart.sdp.md b/examples/checkout-v1/specs/orders/create-order-invalid-cart.sdp.md new file mode 100644 index 0000000..d270c72 --- /dev/null +++ b/examples/checkout-v1/specs/orders/create-order-invalid-cart.sdp.md @@ -0,0 +1,26 @@ +--- +id: spec:orders.create-order.invalid-cart +kind: example +altitude: story +readiness: defined +relations: + refines: spec:orders.create-order + verifies: spec:orders.create-order +--- +# Invalid cart is rejected + +## Intent + +- outcome: Show that an invalid cart does not become an order. +- value: The authored example captures the rejection path without adding invalid fixtures to the model. + +```gwt +Given a customer has a cart with {n: 0} line items +When the customer submits the cart for order creation +Then order creation is rejected because {reason: "empty cart"} +``` + +## Verification — executable + +- The use case throws when the cart is empty. +- The thrown error names the rejection reason. diff --git a/examples/checkout-v1/specs/orders/create-order-invalid-cart.sdp.ts b/examples/checkout-v1/specs/orders/create-order-invalid-cart.sdp.ts deleted file mode 100644 index ac59744..0000000 --- a/examples/checkout-v1/specs/orders/create-order-invalid-cart.sdp.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { refines, spec, specId, verifies } from "@libar-dev/software-delivery-protocol"; - -export const createOrderInvalidCartSpec = spec({ - id: specId("spec:orders.create-order.invalid-cart"), - title: "Invalid cart is rejected", - kind: "example", - altitude: "story", - readiness: "defined", - intent: { - outcome: "Show that an invalid cart does not become an order.", - value: - "The authored example captures the rejection path without adding invalid fixtures to the model.", - }, - // A PARTIAL bound point — the honest form: the example uses only the {n} step, binds n = 0, - // and constrains nothing else (an unused step binds nothing and fails nothing). - behavior: { - examples: [ - { - given: ["a customer has a cart with {n: 0} line items"], - when: ["the customer submits the cart for order creation"], - then: ['order creation is rejected because {reason: "empty cart"}'], - }, - ], - }, - verification: { - mode: "executable", - criteria: [ - "The use case throws when the cart is empty.", - "The thrown error names the rejection reason.", - ], - }, - relations: [ - refines(specId("spec:orders.create-order")), - verifies(specId("spec:orders.create-order")), - ], -}); diff --git a/examples/checkout-v1/specs/orders/create-order-valid-cart.sdp.md b/examples/checkout-v1/specs/orders/create-order-valid-cart.sdp.md new file mode 100644 index 0000000..18f6362 --- /dev/null +++ b/examples/checkout-v1/specs/orders/create-order-valid-cart.sdp.md @@ -0,0 +1,29 @@ +--- +id: spec:orders.create-order.valid-cart +kind: example +altitude: story +readiness: ready +relations: + refines: spec:orders.create-order + verifies: spec:orders.create-order +--- +# Valid cart creates an order + +## Intent + +- outcome: Show that a valid cart can become an order. +- value: The authored example demonstrates the happy path for create-order. + +```gwt +Given a customer has a cart with {n: 2} line items +And every line item has quantity {q: 1} and unit price {price: 50} +And every cart item is {availability: "in stock"} +When the customer submits the cart for order creation +Then an order is created with total {total: 100} +And the order contains the original cart lines +``` + +## Verification — executable + +- The order result contains a stable id. +- The returned total matches the authored total — the spec's value, never the test's. diff --git a/examples/checkout-v1/specs/orders/create-order-valid-cart.sdp.ts b/examples/checkout-v1/specs/orders/create-order-valid-cart.sdp.ts deleted file mode 100644 index 74a7ee8..0000000 --- a/examples/checkout-v1/specs/orders/create-order-valid-cart.sdp.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { refines, spec, specId, verifies } from "@libar-dev/software-delivery-protocol"; - -export const createOrderValidCartSpec = spec({ - id: specId("spec:orders.create-order.valid-cart"), - title: "Valid cart creates an order", - kind: "example", - altitude: "story", - // States ready and earns it: structured GWT evidence, resolving relations, and the test anchor - // (test/orders/create-order.valid-cart.test.ts) binding it as the enabled verifier. - readiness: "ready", - intent: { - outcome: "Show that a valid cart can become an order.", - value: "The authored example demonstrates the happy path for create-order.", - }, - // A bound point in the parent's example space (the concreteness law): every slot in a used - // step binds a value — `{n: 2}`, never `{n}` — and the authored values flow through the - // generated step contract into the bound test, so editing the 100 below reddens the test with - // zero test-side edits. - behavior: { - examples: [ - { - given: [ - "a customer has a cart with {n: 2} line items", - "every line item has quantity {q: 1} and unit price {price: 50}", - 'every cart item is {availability: "in stock"}', - ], - when: ["the customer submits the cart for order creation"], - then: [ - "an order is created with total {total: 100}", - "the order contains the original cart lines", - ], - }, - ], - }, - verification: { - mode: "executable", - criteria: [ - "The order result contains a stable id.", - "The returned total matches the authored total — the spec's value, never the test's.", - ], - }, - relations: [ - refines(specId("spec:orders.create-order")), - verifies(specId("spec:orders.create-order")), - ], -}); diff --git a/examples/checkout-v1/specs/orders/create-order.sdp.md b/examples/checkout-v1/specs/orders/create-order.sdp.md new file mode 100644 index 0000000..d1d400a --- /dev/null +++ b/examples/checkout-v1/specs/orders/create-order.sdp.md @@ -0,0 +1,29 @@ +--- +id: spec:orders.create-order +kind: behavior +altitude: feature +readiness: defined +relations: + refines: spec:orders.order-management + constrainedBy: spec:orders.order-latency-constraint + decidedBy: spec:decisions.order-lifecycle +--- +# Customer creates an order + +## Intent + +- actor: customer +- outcome: Turn a valid cart into an order. +- value: Customers can complete purchases without the example modeling the rest of checkout. + +## Example space + +```gwt-vocabulary +Given a customer has a cart with {n:number} line items +And every line item has quantity {q:number} and unit price {price:number} +And every cart item is {availability:"in stock"|"out of stock"} +When the customer submits the cart for order creation +Then an order is created with total {total:number} +And the order contains the original cart lines +And order creation is rejected because {reason:"empty cart"|"out of stock"} +``` diff --git a/examples/checkout-v1/specs/orders/create-order.sdp.ts b/examples/checkout-v1/specs/orders/create-order.sdp.ts deleted file mode 100644 index 958c04a..0000000 --- a/examples/checkout-v1/specs/orders/create-order.sdp.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - constrainedBy, - decidedBy, - refines, - spec, - specId, -} from "@libar-dev/software-delivery-protocol"; - -export const createOrderSpec = spec({ - id: specId("spec:orders.create-order"), - title: "Customer creates an order", - kind: "behavior", - altitude: "feature", - readiness: "defined", - intent: { - actor: "customer", - outcome: "Turn a valid cart into an order.", - value: "Customers can complete purchases without the example modeling the rest of checkout.", - }, - // The rules and examples stay promoted children — their refines/verifies relations are the - // linkage of record (MD-10), and promoted evidence clears the floor. What the parent owns is - // the EXAMPLE SPACE: the typed step vocabulary its example children bind points in; `sdp build` - // derives the space contract (dimensions · every child's bound point · the Outcome union from - // the then vocabulary) from here. - behavior: { - exampleSpace: { - given: [ - "a customer has a cart with {n:number} line items", - "every line item has quantity {q:number} and unit price {price:number}", - 'every cart item is {availability:"in stock"|"out of stock"}', - ], - when: ["the customer submits the cart for order creation"], - then: [ - "an order is created with total {total:number}", - "the order contains the original cart lines", - 'order creation is rejected because {reason:"empty cart"|"out of stock"}', - ], - }, - }, - relations: [ - refines(specId("spec:orders.order-management")), - constrainedBy(specId("spec:orders.order-latency-constraint")), - decidedBy(specId("spec:decisions.order-lifecycle")), - ], -}); diff --git a/examples/checkout-v1/specs/orders/order-inventory-rule.sdp.md b/examples/checkout-v1/specs/orders/order-inventory-rule.sdp.md new file mode 100644 index 0000000..2087289 --- /dev/null +++ b/examples/checkout-v1/specs/orders/order-inventory-rule.sdp.md @@ -0,0 +1,19 @@ +--- +id: spec:orders.order-inventory-rule +kind: rule +altitude: story +readiness: defined +relations: + refines: spec:orders.create-order +--- +# Order creation requires available inventory + +## Intent + +- outcome: Reject carts whose items are not fully available. +- value: Order creation does not over-promise unavailable stock. + +## Rule + +- Every cart line must have at least the requested quantity available. +- Any unavailable line blocks order creation for the whole cart. diff --git a/examples/checkout-v1/specs/orders/order-inventory-rule.sdp.ts b/examples/checkout-v1/specs/orders/order-inventory-rule.sdp.ts deleted file mode 100644 index f510e59..0000000 --- a/examples/checkout-v1/specs/orders/order-inventory-rule.sdp.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { refines, spec, specId } from "@libar-dev/software-delivery-protocol"; - -export const orderInventoryRuleSpec = spec({ - id: specId("spec:orders.order-inventory-rule"), - title: "Order creation requires available inventory", - kind: "rule", - altitude: "story", - readiness: "defined", - intent: { - outcome: "Reject carts whose items are not fully available.", - value: "Order creation does not over-promise unavailable stock.", - }, - behavior: { - rules: [ - "Every cart line must have at least the requested quantity available.", - "Any unavailable line blocks order creation for the whole cart.", - ], - }, - relations: [refines(specId("spec:orders.create-order"))], -}); diff --git a/examples/checkout-v1/specs/orders/order-latency-constraint.sdp.md b/examples/checkout-v1/specs/orders/order-latency-constraint.sdp.md new file mode 100644 index 0000000..f7bc2ac --- /dev/null +++ b/examples/checkout-v1/specs/orders/order-latency-constraint.sdp.md @@ -0,0 +1,20 @@ +--- +id: spec:orders.order-latency-constraint +kind: constraint +altitude: story +readiness: defined +relations: + refines: spec:orders.create-order +--- +# Create-order latency stays within checkout budget + +## Intent + +- outcome: Keep create-order fast enough for interactive checkout. +- value: Customers are not left waiting after submitting a valid cart. + +## Constraints + +- statement: Create-order should respond within the checkout latency budget. +- flavor: performance +- target: latency.p95.lt:250ms diff --git a/examples/checkout-v1/specs/orders/order-latency-constraint.sdp.ts b/examples/checkout-v1/specs/orders/order-latency-constraint.sdp.ts deleted file mode 100644 index f3f5a64..0000000 --- a/examples/checkout-v1/specs/orders/order-latency-constraint.sdp.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { refines, spec, specId } from "@libar-dev/software-delivery-protocol"; - -export const orderLatencyConstraintSpec = spec({ - id: specId("spec:orders.order-latency-constraint"), - title: "Create-order latency stays within checkout budget", - kind: "constraint", - altitude: "story", - readiness: "defined", - intent: { - outcome: "Keep create-order fast enough for interactive checkout.", - value: "Customers are not left waiting after submitting a valid cart.", - }, - constraints: [ - { - flavor: "performance", - statement: "Create-order should respond within the checkout latency budget.", - target: "latency.p95.lt:250ms", - }, - ], - relations: [refines(specId("spec:orders.create-order"))], -}); diff --git a/examples/checkout-v1/specs/orders/order-management.sdp.md b/examples/checkout-v1/specs/orders/order-management.sdp.md new file mode 100644 index 0000000..0b2e8eb --- /dev/null +++ b/examples/checkout-v1/specs/orders/order-management.sdp.md @@ -0,0 +1,19 @@ +--- +id: spec:orders.order-management +kind: behavior +altitude: epic +readiness: defined +relations: + decidedBy: spec:decisions.order-lifecycle +--- +# Order management + +## Intent + +- outcome: Coordinate the authored order-management slice for checkout v1. +- value: The pack can express order creation behavior without modeling the full checkout flow. + +## Behavior + +- rule: Order management keeps order creation, rules, constraints, and decisions traceable in one authored slice. +- rule: Every order-management child spec keeps its targets inside the checkout-v1 example set. diff --git a/examples/checkout-v1/specs/orders/order-management.sdp.ts b/examples/checkout-v1/specs/orders/order-management.sdp.ts deleted file mode 100644 index 5ee8407..0000000 --- a/examples/checkout-v1/specs/orders/order-management.sdp.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { decidedBy, spec, specId } from "@libar-dev/software-delivery-protocol"; - -export const orderManagementSpec = spec({ - id: specId("spec:orders.order-management"), - title: "Order management", - kind: "behavior", - altitude: "epic", - readiness: "defined", - intent: { - outcome: "Coordinate the authored order-management slice for checkout v1.", - value: "The pack can express order creation behavior without modeling the full checkout flow.", - }, - behavior: { - rules: [ - "Order management keeps order creation, rules, constraints, and decisions traceable in one authored slice.", - "Every order-management child spec keeps its targets inside the checkout-v1 example set.", - ], - }, - relations: [decidedBy(specId("spec:decisions.order-lifecycle"))], -}); diff --git a/examples/checkout-v1/specs/orders/order-model.sdp.md b/examples/checkout-v1/specs/orders/order-model.sdp.md new file mode 100644 index 0000000..d0f89f6 --- /dev/null +++ b/examples/checkout-v1/specs/orders/order-model.sdp.md @@ -0,0 +1,22 @@ +--- +id: spec:orders.order-model +kind: model +altitude: story +readiness: defined +relations: + refines: spec:orders.order-management +--- +# Order-management domain vocabulary + +## Intent + +- outcome: Define the core terms used by the checkout-v1 order-management slice. +- value: Specs, code, and tests use the same vocabulary for carts, orders, and inventory. + +## Model + +- **cart** — A customer-selected set of line items that has not yet become an order. +- **cartLine** — A requested product, quantity, and unit price inside a cart. +- **inventorySnapshot** — The available quantity for each product at validation time. +- **order** — The persisted result of accepting a valid cart. +- **orderTotal** — The sum of all accepted cart line subtotals. diff --git a/examples/checkout-v1/specs/orders/order-model.sdp.ts b/examples/checkout-v1/specs/orders/order-model.sdp.ts deleted file mode 100644 index a81661e..0000000 --- a/examples/checkout-v1/specs/orders/order-model.sdp.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { refines, spec, specId } from "@libar-dev/software-delivery-protocol"; - -export const orderModelSpec = spec({ - id: specId("spec:orders.order-model"), - title: "Order-management domain vocabulary", - kind: "model", - altitude: "story", - readiness: "defined", - intent: { - outcome: "Define the core terms used by the checkout-v1 order-management slice.", - value: "Specs, code, and tests use the same vocabulary for carts, orders, and inventory.", - }, - model: { - terms: { - cart: "A customer-selected set of line items that has not yet become an order.", - cartLine: "A requested product, quantity, and unit price inside a cart.", - inventorySnapshot: "The available quantity for each product at validation time.", - order: "The persisted result of accepting a valid cart.", - orderTotal: "The sum of all accepted cart line subtotals.", - }, - }, - relations: [refines(specId("spec:orders.order-management"))], -}); diff --git a/examples/checkout-v1/specs/orders/order-placement-flow.sdp.md b/examples/checkout-v1/specs/orders/order-placement-flow.sdp.md new file mode 100644 index 0000000..792559f --- /dev/null +++ b/examples/checkout-v1/specs/orders/order-placement-flow.sdp.md @@ -0,0 +1,21 @@ +--- +id: spec:orders.order-placement-flow +kind: workflow +altitude: feature +readiness: scoped +relations: + refines: spec:orders.order-management + dependsOn: spec:orders.create-order +--- +# Order placement flow + +## Intent + +- outcome: Sequence cart submission through validation to a created order. +- value: The slice states how create-order is reached without modeling the rest of checkout. + +## Workflow + +- The customer submits a cart from checkout. +- Create-order validates the cart against the inventory snapshot. +- A valid cart becomes an order; an invalid cart returns a validation error. diff --git a/examples/checkout-v1/specs/orders/order-placement-flow.sdp.ts b/examples/checkout-v1/specs/orders/order-placement-flow.sdp.ts deleted file mode 100644 index bb837df..0000000 --- a/examples/checkout-v1/specs/orders/order-placement-flow.sdp.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { dependsOn, refines, spec, specId } from "@libar-dev/software-delivery-protocol"; - -export const orderPlacementFlowSpec = spec({ - id: specId("spec:orders.order-placement-flow"), - title: "Order placement flow", - kind: "workflow", - altitude: "feature", - readiness: "scoped", - intent: { - outcome: "Sequence cart submission through validation to a created order.", - value: "The slice states how create-order is reached without modeling the rest of checkout.", - }, - // Flows are scoped-rung evidence (MD-12): the sequencing is stated, but the workflow carries no - // rules or examples yet — so it honestly states scoped, not defined. - behavior: { - flows: [ - "The customer submits a cart from checkout.", - "Create-order validates the cart against the inventory snapshot.", - "A valid cart becomes an order; an invalid cart returns a validation error.", - ], - }, - relations: [ - refines(specId("spec:orders.order-management")), - dependsOn(specId("spec:orders.create-order")), - ], -}); diff --git a/examples/checkout-v1/specs/orders/order-total-rule.sdp.md b/examples/checkout-v1/specs/orders/order-total-rule.sdp.md new file mode 100644 index 0000000..d9d872c --- /dev/null +++ b/examples/checkout-v1/specs/orders/order-total-rule.sdp.md @@ -0,0 +1,19 @@ +--- +id: spec:orders.order-total-rule +kind: rule +altitude: story +readiness: defined +relations: + refines: spec:orders.create-order +--- +# Order total matches cart math + +## Intent + +- outcome: Keep the order total equal to the sum of cart line subtotals. +- value: Customers and downstream systems see one deterministic order total. + +## Rule + +- Each line subtotal is quantity multiplied by unit price. +- The order total is the sum of all line subtotals. diff --git a/examples/checkout-v1/specs/orders/order-total-rule.sdp.ts b/examples/checkout-v1/specs/orders/order-total-rule.sdp.ts deleted file mode 100644 index 44a8f61..0000000 --- a/examples/checkout-v1/specs/orders/order-total-rule.sdp.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { refines, spec, specId } from "@libar-dev/software-delivery-protocol"; - -export const orderTotalRuleSpec = spec({ - id: specId("spec:orders.order-total-rule"), - title: "Order total matches cart math", - kind: "rule", - altitude: "story", - readiness: "defined", - intent: { - outcome: "Keep the order total equal to the sum of cart line subtotals.", - value: "Customers and downstream systems see one deterministic order total.", - }, - behavior: { - rules: [ - "Each line subtotal is quantity multiplied by unit price.", - "The order total is the sum of all line subtotals.", - ], - }, - relations: [refines(specId("spec:orders.create-order"))], -}); diff --git a/jtbd-stories/01-capture-and-evolve-intent.md b/jtbd-stories/01-capture-and-evolve-intent.md index 70825d3..116af21 100644 --- a/jtbd-stories/01-capture-and-evolve-intent.md +++ b/jtbd-stories/01-capture-and-evolve-intent.md @@ -16,7 +16,7 @@ The job here is to get a thought into the system and let it grow without ever fo **Acceptance criteria:** 1. A spec can be created at `readiness: "idea"` with only `id`, `title`, `kind`, `altitude`, and either `intent.outcome` or a parent relation — nothing else is required. -2. The new spec lives in a carrier document under the extraction root (conventionally `/specs/`) — `*.sdp.md` for a Markdown-canonical ID (new IDs may be born Markdown-canonical now that the product parser has landed — the interim carrier rule, MD-18), `*.sdp.ts` for a TS-canonical pre-existing ID — as committed content, immediately part of the single source of truth — no status field, ticket, or external tool is needed for it to "exist." +2. The new spec lives in a carrier document under the extraction root (conventionally `/specs/`) — Markdown `*.sdp.md` by default, with `*.sdp.ts` lawful per ID — as committed content, immediately part of the single source of truth — no status field, ticket, or external tool is needed for it to "exist." Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option (the carrier ruling, MD-18). 3. Open questions can be attached (`intent.openQuestions`) without resolving them and without blocking capture; only questions explicitly marked `blocking` constrain stating `defined`/`ready` later. 4. The spec is valid at its lowest readiness — the build never demands rules, anchors, or tests to accept an idea. 5. The spec source is static, side-effect-free data in its carrier ("a JSON file that TypeScript happens to validate" in the TS carrier; bounded frontmatter and owned prose in the Markdown carrier), so the extractor reifies it deterministically. diff --git a/jtbd-stories/README.md b/jtbd-stories/README.md index f13ce13..2c38f43 100644 --- a/jtbd-stories/README.md +++ b/jtbd-stories/README.md @@ -26,7 +26,7 @@ Stories are tagged so the backlog stays MVP-disciplined without capping ambition | **Iterate** | Adds real power once the MVP loop holds. The natural next layer. | | **Later** | Valuable and designed-for, but not soon. Kept here so the model doesn't paint us into a corner. | -The MVP target is one bounded context — Order Management, `pack:checkout-v1`, ~8–12 specs — proving: the typed `Spec` envelope and its carriers (the TS DSL for the checkout worked example; the ruled Markdown carrier for new IDs) + generic anchors, the one-graph extractor over both carriers (`ts-morph` for `.sdp.ts`, the ruled Markdown parser for `.sdp.md`), core conformance + honesty checks, the Design Review / one read-only view, the agent surface (the `reader` — entry adapters + impact), and the bidirectional spec↔test trace. The write path is **edit the canonical carrier + git** — no patch subsystem. +The MVP target is one bounded context — Order Management, `pack:checkout-v1`, ~8–12 specs — proving: the typed `Spec` envelope and its carriers (Markdown by default, with the TS DSL as an import source and lawful per-ID option; Packs remain TS until a Pack syntax ruling) + generic anchors, the one-graph extractor over both carriers (`ts-morph` for `.sdp.ts`, the ruled Markdown parser for `.sdp.md`), core conformance + honesty checks, the Design Review / one read-only view, the agent surface (the `reader` — entry adapters + impact), and the bidirectional spec↔test trace. Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option. The write path is **edit the canonical carrier + git** — no patch subsystem. --- diff --git a/package.json b/package.json index a1b9231..9145d65 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "import": "./dist/index.js", + "require": "./dist/index.cjs" }, "./runner": { "types": "./dist/runner/index.d.ts", @@ -42,8 +43,8 @@ "format:check": "prettier --check .", "check:temporal": "node ./check-temporal.mjs", "generate:example": "node ./dist/cli/sdp.js build examples/checkout-v1", - "generate:self-hosting": "node ./dist/cli/sdp.js view . --exclude explorations --exclude examples", - "check:self-hosting": "node ./dist/cli/sdp.js view . --exclude explorations --exclude examples --check-clean", + "generate:self-hosting": "node ./dist/cli/sdp.js view . --exclude explorations --exclude examples --exclude test/fixtures/import/parity", + "check:self-hosting": "node ./dist/cli/sdp.js view . --exclude explorations --exclude examples --exclude test/fixtures/import/parity --check-clean", "check:example": "node ./dist/cli/sdp.js view examples/checkout-v1 --check-clean", "preflight": "node ./preflight.mjs", "check": "npm run check:temporal && npm run lint && npm run format:check && npm run build && npm run generate:self-hosting && npm run generate:example && npm run typecheck && npm run typecheck:examples && npm test && npm run check:self-hosting && npm run check:example && npm run preflight" diff --git a/plans/05-de-temporalization-and-decisions-registry.md b/plans/05-de-temporalization-and-decisions-registry.md index 332e9e8..d768d11 100644 --- a/plans/05-de-temporalization-and-decisions-registry.md +++ b/plans/05-de-temporalization-and-decisions-registry.md @@ -186,20 +186,20 @@ Future spec id**: | ID | Ratified name | Curation | Future spec id | |---|---|---|---| -| MD-1 | the executable meta-model | durable | `spec:protocol.decisions.executable-meta-model` | -| MD-2 | adopt the nouns, reject the gates | durable | `spec:protocol.decisions.adopt-the-nouns` | -| MD-4 | one primitive, named coordinates | durable | `spec:protocol.decisions.one-primitive` | -| MD-5 | the protocol naming | durable | `spec:protocol.decisions.protocol-naming` | -| MD-7 | binding, never liveness | durable | `spec:protocol.decisions.binding-not-liveness` | +| MD-1 | the executable meta-model | durable | `spec:decisions.executable-meta-model` | +| MD-2 | adopt the nouns, reject the gates | durable | `spec:decisions.adopt-the-nouns` | +| MD-4 | one primitive, named coordinates | durable | `spec:decisions.one-primitive` | +| MD-5 | the protocol naming | durable | `spec:decisions.protocol-naming` | +| MD-7 | binding, never liveness | durable | `spec:decisions.binding-not-liveness` | | MD-8 | the generic `codeAnchor` | folds at Slice 2 → doc-comment on the builder | — | | MD-9 | the open-questions home | folds at fold time (lives in `sections.ts`, the floor, `02` §3) | — | -| MD-10 | content-only sections | durable | `spec:protocol.decisions.content-only-sections` | -| MD-11 | the typing law | durable | `spec:protocol.decisions.typing-law` | -| MD-12 | the kind-conditional floor | durable | `spec:protocol.decisions.kind-conditional-floor` | +| MD-10 | content-only sections | durable | `spec:decisions.content-only-sections` | +| MD-11 | the typing law | durable | `spec:decisions.typing-law` | +| MD-12 | the kind-conditional floor | durable | `spec:decisions.kind-conditional-floor` | | MD-13 | floor-table-as-truth | folds (lives in `05` §3 Representation note + `readiness-floor.ts` header) | — | -| MD-14 | one validation path | durable | `spec:protocol.decisions.one-validation-path` | -| MD-15 | the `.sdp.ts` extension | durable | `spec:protocol.decisions.sdp-ts-extension` | -| MD-16 | carried evidence | durable (classification added here — Report 2's gap) | `spec:protocol.decisions.carried-evidence` | +| MD-14 | one validation path | durable | `spec:decisions.one-validation-path` | +| MD-15 | the `.sdp.ts` extension | durable | `spec:decisions.sdp-ts-extension` | +| MD-16 | carried evidence | durable (classification added here — Report 2's gap) | `spec:decisions.carried-evidence` | Below the table, the convention line: *"In prose, lead with meaning — write 'the typing law (MD-11),' never bare 'MD-n.' (Generalizes MD-8's own footnote.)"* diff --git a/plans/17-self-hosting-v1.md b/plans/17-self-hosting-v1.md index 2a7e3aa..5b8e5e0 100644 --- a/plans/17-self-hosting-v1.md +++ b/plans/17-self-hosting-v1.md @@ -432,9 +432,8 @@ Intent bullets during todo-9 enrichment. It was born `scoped` and matured to `de only non-byte-exact corpus row relative to the frozen table, intentionally retained because the added evidence satisfies the `defined` floor. -**Back-catalog notes.** Authored decision specs use `spec:decisions.*`, while the DECISIONS registry -reserves `spec:protocol.decisions.*`; resolve that namespace divergence in the back-catalog fold, -not in phase 1. The `npm run check` chain now has a twelfth `preflight` leg beyond todo-22's frozen +**Back-catalog notes.** Authored decision specs and the DECISIONS registry use `spec:decisions.*`; +the back-catalog fold activates the reserved IDs after phase 1. The `npm run check` chain now has a twelfth `preflight` leg beyond todo-22's frozen eleven-leg order. This is a benign additive deviation, and `AGENTS.md` documents the current chain. **Low and information observation dispositions.** The review labels this tail as 24 observations; diff --git a/plans/18-self-hosting-phase-2-brief.md b/plans/18-self-hosting-phase-2-brief.md new file mode 100644 index 0000000..519d3ee --- /dev/null +++ b/plans/18-self-hosting-phase-2-brief.md @@ -0,0 +1,467 @@ +# Plan 18 brief — Self-hosting phase 2: the carrier flip · the decision fold · corpus growth + +> **Status: 🧭 BRIEF — planning input, not an execution plan.** This is the owner's commissioning +> brief for the phase-2 detailed implementation plan, to be authored by oh-my-openagent's +> Prometheus planning agent. The detailed plan lands as `plans/18-self-hosting-phase-2.md`; this +> brief then stands as its recorded input. Until that plan is DRAFTED, the latest settled ground +> remains plan 17 (`plans/17-self-hosting-v1.md`, ✅ EXECUTED). Work happens on the effort branch +> `feature/protocol-self-application-phase-2` — never on `main`. +> +> **Mandate.** The owner's phase-2 decision at Gate 4 (plan 17 §6) scheduled the `sdp import` +> emitter, the checkout-v1 migration, and the canonical-default flip as **one phase-2 effort**, +> including the like-for-like parser-hardening baseline, with the table-sugar ruling expected to +> fire under it. This brief confirms that mandate and extends the same plan to two further +> tranches: the **decision-spec fold** and the next **corpus-expansion** wave. These are items +> 1–3 of the "What remains for full self-hosting" enumeration in the **merged phase-1 PR +> description** — cited by source because that list lives in no plan file; this brief (§3, §8) +> is now its durable record. Items 4 (the systematic tests-to-executable-specs rewrite) and 5 +> (per-doc concept deletion) stay later work. + +## 1. The goal in one paragraph + +Phase 1 made the Protocol's own delivery model a first-class citizen of its own machinery — 15 +Markdown-canonical specs, one pack, an executable tracer, the full repository gate. But the repo +still runs a **three-state carrier world** (Markdown-canonical new IDs · TS-canonical pre-existing +IDs · the TS checkout example), the decision diary still carries sixteen durable rulings as prose +with a divergent reserved namespace, and most of the concept corpus (`docs/concept/00–07`) still +holds semantic contract no spec carries. Phase 2 collapses the carrier world to one rule +(Markdown canonical by default, the TS DSL surviving as import source and per-ID option), folds +every durable decision into a `decision`-kind spec under a strict durability rubric, and grows +the corpus one subsystem at a time so concept-doc content starts living as typed, anchored, +checkable Specs. The flip is the precondition for the rest: the corpus cannot absorb the concept +docs while three carrier states coexist. + +## 2. Settled ground the plan builds on (read, don't re-derive) + +- **The carrier ruling (MD-18)**, transition clause as amended by plan 17: new spec IDs may be + born Markdown-canonical; pre-existing IDs and checkout-v1 stay TS-canonical **until the ruled + flip** — the product parser (landed), `sdp import` (not started), and the checkout-v1 migration + (not started). Surviving law: **one canonical surface per ID, no mixing**. The interim records + are pinned by `check-carrier-interim.mjs` and stated in `AGENTS.md`, `CONTEXT.md`, and + DECISIONS MD-18 — all three must move together again at the flip (the operative-record gate + pattern, plan 17). +- **The product Markdown parser** — corpus-scoped by design; the parser specs pin an explicit + **non-claim** of full refusal parity with the TS carrier. Gate 4 names discharging this (the + like-for-like parser-hardening baseline / C2-parity obligation) as part of the phase-2 effort. +- **The self-hosting corpus**: 15 specs under `specs/` (carrier ×5, extraction ×3, validation ×3, + model ×1, protocol ×1, decisions ×2) plus `pack:self-hosting-v1` + (`specs/self-hosting.pack.sdp.ts` — the pack manifest lawfully stays TS, the + one-canonical-surface law being per-ID). `spec:carrier.sdp-import` is honestly authored at + `idea` and is tranche 1's spec to enrich in place. +- **The fold seed**: the ratified-name registry (`docs/concept/DECISIONS.md` top) marks 16 rows + **durable** with reserved future IDs under `spec:decisions.*`, matching the two born decision + specs. The fold activates that ratified namespace. Rows marked *folded/folds* (MD-8, MD-9, + MD-13) already have carrying surfaces (doc-comments, `sections.ts`, the floor table) — verify, + don't re-fold. +- **Open watch items** (rule only under fire, each ruling recorded): table-sugar syntax · + the single-literal vocabulary form · the multi-entry constraint form · the array-section prose + sub-owner · Markdown `Pack` syntax. Gate 4 expects table sugar to fire in this phase — an + expectation on the record, **never a quota**: the sugar's semantics are already ruled + (point-per-example (MD-17): a table statically expands to N sibling examples), only the + Markdown syntax is unruled, and checkout today models multi-case as sibling `example` specs + without tables. If the grammar matrix never forces it, the watch item survives unfired + (acceptance 6). +- **Deferred-debt tail**: plan 17 §6's review-06 reconciliation rows marked + `pre-existing/phase-2` or `deferred` (exclude UX diagnostics, Design Review escaping policy, + stale provenance wording, grammar-hardening polish, …). The plan must sweep this tail and + adopt the rows that naturally ride these tranches; the rest carry forward in the docket, never + silently drop. + +## 3. Scope — three tranches, one plan + +### Tranche 1 — end the interim carrier rule (the flip) + +Discharge the carrier ruling's transition clause completely. **Ruled order (§6): emitter → +checkout migration → hardening baseline → flip** — grammar growth is forced by the migration, +parity is paid once against the settled grammar, and the default never points at a surface that +has not earned its hardening claim: + +1. **The `sdp import` emitter — a durable product verb (§6), never a migration script.** + Reified TS carrier → authored `.sdp.md`, honoring the ruled + grammar (envelope subset, heading → section map, `gwt`/`gwt-vocabulary` fences, prose + ownership). Enrich `spec:carrier.sdp-import` from `idea` upward only as the floor honestly + clears. Fidelity is proven by **round-trip graph equality**: import a TS spec, extract the + emitted Markdown, and compare graphs modulo explicitly ruled deltas (any delta is itself a + ruling to record, never a shrug). **Three bindings pinned here:** (a) the ratified glossary + already defines `sdp import` as *the one-way gen-1 `.feature` converter* (`CONTEXT.md`, the + executable half) — reusing the name for the TS→MD emitter is a **load-bearing glossary + amendment made on the record** (the natural reconciliation: one import verb, many source + adapters, sharing the glossary's own "document emitter authored once in the winning + carrier"; a distinct verb is the recorded alternative), never a silent CLI add-on — and the + phase-2 adapter inventory is **TS-only**, the gen-1 `.feature` source staying designed-in + and deferred on the record, never silently abandoned; (b) the round-trip **delta catalog is + pre-declared** in the plan (at minimum: the `file` path suffix `.sdp.ts`→`.sdp.md`; physical + envelope differences; TS source comments, which are not graph content; delivery facts + compared under the same anchor set) — equality is never redefined mid-flight; (c) fidelity + is **two-layered** — graph equality is the semantic gate only; the plan also sets an + authoring-surface bar (emitted Markdown idiomatic under the ruled grammar and the + prose-ownership law, judged at Design Review with fixture evidence), so round-trip green + never blesses unreadable Markdown no agent could have authored. One honesty note: the + per-ID option today is realized purely by *which file exists* (both surfaces for one ID = + the duplicate finding); the flip invents **no** canonical-surface config unless the plan + rules one as an explicit deliverable — and whichever way it rules, `04` §1's stronger + standing claim ("a per-ID canonical-surface config decides which surface is canonical; the + other is a generated read-only view") is reconciled on the record (repaired to aspirational + wording, or the minimal mechanism ships) rather than left contradicting the file-existence + realization. +2. **The checkout-v1 migration.** Every checkout spec ID moves to `.sdp.md` via the emitter; the + TS spec carriers for migrated IDs are deleted **in the same change** — no mixed interval on + the branch history that `npm run check` ever blesses. The example's README walkthrough is + rewritten for the Markdown surface. Determinism stays regenerate-and-compare + (`--check-clean`), never committed goldens. +3. **The parser-hardening baseline.** Convert the pinned non-claim into either (a) a + like-for-like refusal-parity claim with pinned evidence, or (b) a revised, explicitly scoped + non-claim recorded on the parser specs — never a silent lapse; the plan **defines the + evidence bar** (parity measured against the TS reifier's refusal behavior as a bounded + matrix — never open-ended "hardened"). The checkout corpus is the forcing function for + grammar growth: the plan inventories a **checkout grammar matrix** (kind × section × + phase-1 support, against the real `examples/checkout-v1` tree — 11 specs across all eight + kinds) *before* scheduling any syntax ruling; whatever the matrix forces beyond the phase-1 + subset gets ruled under fire and recorded, per the phase-1 discipline. The review-06 + grammar-polish cluster rides this baseline (§6 debt intake). +4. **The canonical-default flip — the tranche's capstone.** The default authoring carrier + becomes Markdown for all IDs; the TS DSL is **not retired** — it survives exactly as the + carrier ruling says, import source + per-ID option (the `.sdp.ts` extension law (MD-15) + already re-points to the `.sdp.md` sibling; check its wording again post-flip). The + post-flip operative sentence is honest about every residue: *Specs default to Markdown; + Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a + lawful per-ID option.* The flip is + an **operative-record gate** mirroring phase 1's: DECISIONS MD-18's Decision text, the + `CONTEXT.md` resolved entries (the carrier row **and the amended `sdp import` term**), and + `AGENTS.md` land in one commit or a tight series with no intermediate green claim + contradicting them; the pinned consistency scripts (`check-carrier-interim.mjs`, plus the + interim/Gate-4 strings in `check-carrier-truth.mjs` and `check-self-hosting-gates.mjs`) are + **replaced by post-flip truth pins or retired** in the same series — the machine check that + records and law agree survives the flip; a **post-flip anti-misleading pass** repairs every + remaining interim-rule claim, its inventory seeded from `check-carrier-truth.mjs`'s audit + map (concept docs `00`/`04`/`07`/README, JTBD stories, the example README, MD-15's wording, + package docs). + +### Tranche 2 — the decision-spec fold + +Every **durable** row of the ratified-name registry becomes a `decision`-kind spec; +`docs/concept/DECISIONS.md` shrinks to a **lean registry of pointers** (name · one-line gloss · +spec pointer · the folded/CONTEXT-only dispositions). The fold is governed by the routing rubric +in §4 — it is a *distillation*, never a diary transplant: + +- **The namespace is ruled — `spec:decisions.*`** (§6). The fold's first act amends the + registry's "Future spec id" column to match; the two born specs keep their IDs and every + existing reference stands. +- **Relation shape (§6):** each fold decision `refines` the most-specific subsystem spec it + rules (fallback: `spec:protocol.self-hosting` for genuinely phase-level decisions); specs a + decision shapes author `decidedBy` back-edges. The fold therefore runs **after** tranche 3's + parent-creating waves (execution order 1 → 3 → 2). +- **Per-row disposition, recorded in a fold ledger** (a plan process record, not graph content): + each of the 16 durable rows plus the legacy structural shorthand (D1–D6) and the + measured-evidence section gets exactly one disposition — `decision spec` · `CONTEXT.md only` + (pure terminology) · `ordinary spec` (design intent, not a ruling) · `carried by code/tests — + verify the surface` · `retired (git is the archive)`. The *folds* rows (MD-9, MD-13) are + verified, not re-authored. +- **The diary bodies retire once carried.** Git history is the archive of record; the plan + decides whether any body text moves to `reviews/`-style archive or is simply deleted with the + lean registry pointing at specs. What never dissolves: `CONTEXT.md` as the ratified glossary, + the lean registry itself, `plans/` as the process record. +- **Not architecture-only.** The `decision` kind covers any durable delivery ruling — naming, + posture, dependency pins, process laws (the plain-language-references decision is the + template) — not merely architectural shape. + +### Tranche 3 — corpus expansion, one subsystem at a time + +Grow `pack:self-hosting-v1` beyond the 15 so concept-doc semantic contract is progressively +carried by typed, anchored Specs — the dissolution decision +(`spec:decisions.concept-docs-dissolve`) names the criterion; deletion stays out of scope. + +- **All four subsystem waves ride phase 2 (§6)**, sequential and owner-gated (one subsystem at + a time as working pace, never as phase scope): the core model (`02`) → `model`/`behavior`/ + `rule` specs for the primitive, the three descriptors, sections, and relations · validation + (`05`) → `rule`-kind specs over the validator registry (11 check ids today) — a **pressure + target, never a count KPI**: bundling is lawful where one law spans several mechanical + checks (the anti-filler rule wins; acceptance 4's disposition-over-count spirit), floor + clauses as evidence · consumers (`06`) + → reader and projection specs · extraction and the one graph (`03`) → deepening the phase-1 + seeds. The fold's refine-target minimum (core model · validation · a consumers parent) rides + the earliest waves so tranche 2 is never blocked. +- **Anchored to the real engine.** Precise anchors on existing entrypoints keep `implemented` + derived and honest; readiness is stated only where the floor genuinely clears; a kind is + authored only where real content forces it (thin filler specs are a defect). +- **Coverage trail.** Each new spec names, in the plan's coverage ledger (process record, never + graph content), which concept-doc sections' contract it carries — building the per-doc + deletion trail the dissolution decision will later check at a Design Review. +- **Executable where cheap.** The duplicate-IDs tracer is the template; where a behavior's + verify loop is genuinely cheap, the spec may earn `has-verifier` via `gwt-vocabulary` → + `example` child → generated contract → `bindExample` test with a test anchor. The + *systematic* tests-to-executable-specs rewrite remains out of scope. + +## 4. The content-routing rubric (binding authoring guidance for every tranche) + +The owner's hard constraint for this phase: **a decision spec carries only durable law — content +that is very expensive to change — and zero procedural or execution context.** This is authoring +discipline enforced at review gates; it is **never a validator** (checks police conformance and +honesty, never content-quality — the permanent guardrail stands). + +| Content | Home | +|---|---| +| A durable ruling itself: the law, non-obvious rejected alternatives, non-obvious consequences | `decision`-kind spec | +| A ruling that is *only* terminology (a term, its definition, its avoid-list) | `CONTEXT.md` (the ratified glossary) — no decision spec | +| Design intent not yet implemented | ordinary `Spec` of the honest kind and readiness | +| Behavior already implemented and worth verifying | spec + precise anchors; the executable loop (contract + bound test) where the loop is cheap | +| Procedural/execution context: dates, commit SHAs, session/slice/todo numbers, status stamps, review provenance, "landed at …" | **never in a spec** — git history and the plan done-records already carry it | + +Litmus tests for the fold author: + +1. **The rewrite test** — would the sentence still be true and load-bearing if the repo were + re-implemented from scratch tomorrow under the same design? If not, it is execution context. +2. **The three-part test** (already ratified in the registry): hard to reverse · surprising + without context · a real trade-off. All three, or it is not a decision spec. +3. **Leanness** — the decision slots (context · decision · rationale · consequence) run 1–3 + sentences each unless rejected alternatives or consequences genuinely earn more (plan 17's + standing style directive). The two born decision specs are the size template. +4. **Current law only** — a decision spec states the law as it stands; amendment history lives + in git, not in the spec body. +5. **Plain language leads** — meaning first, codes parenthetical and only where cross-referencing + needs them (the plain-language references decision). + +**Decisions in flight** (the pending-decision pattern — already ruled by the open-questions home +(MD-9) and the typing law (MD-11); this clause applies it, never re-decides it): a durable +question not yet ruled becomes a `decision`-kind spec at honest low readiness — raised → +explored → written → ratified is the decision-kind gloss on `idea → scoped → defined → ready` — +with the unresolved branch as a blocking entry in `intent.openQuestions`; the floor makes a +blocking question and stated `defined` mutually exclusive, so "pending" is machine-enforced, +never a label. A question not yet durable stays a plan record. A partially ruled decision splits +by refinement when the settled core must be consumable now: parent at `defined`+ carrying the +ruled law, a child at `idea`/`scoped` (`refines` parent) carrying the open branch. Replacement is +always the `supersedes` relation — "superseded" is a graph query, never an authored status — and +**no status vocabulary, ever** (`decision.status` is rejected vocabulary; ADR status frontmatter +must not leak in). Rejected paths are not truth-specs; they live in the winning decision's +alternatives/consequences. + +External soft guidance (calibration, not authority — the repo's own ratified rules win on any +conflict): `~/.agents/skills/grill-with-docs/ADR-FORMAT.md` (the minimal-ADR shape and the +what-qualifies list — read broadly, **not** limited to architectural decisions) and +`~/.agents/skills/grill-with-docs/CONTEXT-FORMAT.md` (tight, opinionated glossary entries). + +## 5. Constraints and invariants that bind the plan + +- **One canonical surface per ID, no mixing** — the carrier ruling's surviving law; the + migration must never leave a blessed commit with a dual-authored ID. +- **Checks police conformance and honesty, never content-quality and never workflow**; and we + claim "deterministically validated," never "provably correct." +- **Delivery facts are derived, never authored**; the claim taxonomy is never collapsed; + readiness is stated only where the floor clears — the corpus stays floor-honest at every + intermediate commit the gate blesses. +- **Intended truth vs realization**: a disagreement found during the fold or expansion is drift + to resolve deliberately — never silent promotion of code behavior into intent. +- **The dual-authority window has a precedence rule**: while concept docs still restate laws + the fold has carried into decision specs, the specs are authoritative for settled law and + concept prose is exposition — a conflict is drift repaired toward the specs (or the prose + marked expository), never resolved by silently promoting code behavior into either surface. + The dissolution decision governs when the prose side may shrink. +- **Ratified terminology end-to-end** — the effort is *self-hosting* (never "dogfooding"); new + terms are flagged, not silently invented. +- **Tracer-bullet discipline** — if a migrated or newly authored spec cannot be expressed + cleanly, fix the carrier or the emitter, never the spec. +- **Rulings under fire are recorded when made** — in the plan's running log and the corpus + specs; only rulings passing the three-part test also enter the lean registry. +- **Git hygiene** — no `git stash`; commit early on the effort branch; commit/push only when the + owner asks. + +## 6. Owner rulings — the open questions, grilled and resolved (2026-07-18) + +These began as open questions; a grill session with the owner resolved all eight before +Prometheus planning. The detailed plan inherits them as **settled input** — re-litigating any of +them requires an explicit owner gate. Rulings that pass the three-part test enter the fold as +decision specs when their tranche lands; until then this brief is their record. + +1. **The decision-spec namespace** — **RESOLVED (owner, this brief): `spec:decisions.*`.** The + born form is ratified: two segments like every corpus spec, the `specs/decisions/` subdir + mirrors the domain layout, and the reserved three-segment form would collide with the + dotted-lineage convention (`spec:validation.duplicate-ids.dual-carrier` = child). The + registry's "Future spec id" column is amended at fold start; no re-ID, no reference churn. +2. **`sdp import`'s standing** — **RESOLVED (owner, this brief): durable product verb.** + `sdp import` joins the CLI surface permanently — TS carrier → `.sdp.md`, refusal-honest like + the parser (findings, never throws). Grounded in the carrier ruling's own wording (the TS DSL + is an "import source + per-ID option" — a standing role implies a standing surface); the + second-caller bar is met by ratified implication (the checkout migration now; every future + per-ID TS→MD flip after). `spec:carrier.sdp-import` may mature toward `ready` with a real + verifier as the floor honestly clears. +3. **Sequencing inside tranche 1** — **RESOLVED (owner, this brief): emitter → migration → + hardening → flip.** The checkout migration runs on the corpus-scoped parser and forces + grammar growth under fire (table sugar expected); the parity baseline lands against the + *settled* post-migration grammar (never paid twice); the canonical-default flip and its + operative-record gate close the tranche — the default never points at a surface that has not + earned its hardening claim. +4. **The pack question** — **RESOLVED (owner, this brief): the watch item stands.** Both pack + manifests lawfully remain `.pack.sdp.ts` — the one-canonical-surface law is per-ID and the + `Pack` is not a kind, so the carrier ruling ("Markdown, all eight kinds") never covered it. + Markdown pack syntax is ruled only when a real need forces it; the + TS-toolchain-for-pack-authoring wart is named in the docket, not speculatively fixed. +5. **Fold relation targets** — **RESOLVED (owner, this brief): refine the ruled domain; the + corpus wave precedes the fold.** Each fold decision `refines` the most-specific subsystem + spec it rules; specs it shapes author `decidedBy` back-edges; `spec:protocol.self-hosting` + remains the fallback parent only for genuinely phase-level decisions. **Corollary — execution + order is tranche 1 → tranche 3 (first wave) → tranche 2:** the subsystem parent specs must + exist before the fold authors its refine targets. The order is a **targets-exist condition, + never a full-tranche barrier** — the fold starts once the refine-target minimum (core model + · validation · a consumers parent) exists, and remaining tranche-3 waves may continue + alongside or after it. The two born specs may be re-pointed at the fold if a more specific + parent then exists (a relation edit, not an ID change). +6. **The diary's residue** — **RESOLVED (owner, this brief), three parts.** (a) *Bodies*: once a + durable row's law is distilled into its decision spec, the body leaves the working file — + git history is the archive of record; the lean registry keeps name · one-line gloss · spec + pointer. (b) *D1–D6*: honor the table's own KEEP note — D3 (Pack reified), D5 (the agent + surface scripts the graph), and D6 (the MCP-deferred no) join the fold as decision specs; + D1/D2/D4 are carried by tranche-3 subsystem specs; the table survives in the lean registry + until the artifacts citing `(Dn)` dissolve. (c) *Measured evidence*: per-figure disposition + in the fold ledger — each figure moves into the spec that carries its generalized claim as + that spec lands in tranche 3; the section shrinks row-by-row and retires when empty. +7. **Tranche 3 sizing** — **RESOLVED (owner, this brief): all four subsystem waves ride + phase 2** — the core model (`02`), extraction and the one graph (`03`), validation (`05`), + and consumers (`06`). Waves run sequentially and owner-gated (one subsystem at a time as + working pace, not as phase scope); OmO execution capacity is not the constraint (phase 1's + four sessions landed in one). The fold's refine-target minimum (core model + validation + + a consumers parent) rides the earliest waves so tranche 2 is never blocked. The phase-3 + remainder shrinks to the systematic tests-to-executable-specs rewrite and per-doc deletion. +8. **Post-flip debt intake** — **RESOLVED (owner, this brief): adopt by cluster.** The + grammar-hardening cluster (YAML scalar spellings · line-number polish · the `...` closer · + cap-flood behavior · heading/GWT strictness · duplicate-`When` · reifier catch-all totality) + rides the parity baseline; the exclude/CLI cluster (Windows absolutes · `--exclude --foo` + diagnostics · matcher regression coverage · library-seam wording) rides tranche 1; the + Design Review cluster (dynamic-key ordering · the escaping-outside-prose-slots policy) is + ruled at migration/flip, when the TS-carrier question narrows; the records cluster (stale + provenance wording · plan-16 evidence dispositions · the carrier-truth comment) rides the + fold. Only the no-reparse spy seam and temporal token assembly stay deferred — polish with + no phase-2 surface. + +## 7. Acceptance criteria (the plan must make these checkable) + +1. **The flip is total and recorded**: no TS-canonical **product spec ID** remains under the + gate's blessed roots — pack manifests lawfully stay TS (§6), the dual-carrier **test + fixtures** keep both surfaces by design, and the TS DSL itself is not retired (import source + + per-ID option, per the carrier ruling); MD-18's transition clause is discharged across all + operative surfaces in step; the pinned consistency scripts are retired/re-pointed; the + anti-misleading pass leaves no active interim-rule claim. +2. **Round-trip fidelity is proven**: import → extract → graph equality for every migrated + checkout ID under the **pre-declared delta catalog**, which lands in the done-record (never + an ad-hoc test ignore); `npm run check` green on the full clean-clone sequence, plus the + installed-package proof for any changed public surface. The authoring-surface fidelity bar + is judged at the migration's Design Review — a second layer, never collapsed into graph + equality. +3. **The hardening non-claim is resolved** — replaced by a parity claim with pinned evidence or + an explicit, recorded, narrower non-claim — and the chosen claim text lands on the parser + specs themselves (the phase-1 non-claim discipline). +4. **The fold is complete by disposition, not by count**: every registry row, D1–D6, and the + measured-evidence section carries exactly one recorded disposition, the folded/folds rows + verified with named evidence surfaces; every `decision`-kind spec passes the §4 rubric on + review; `DECISIONS.md` is the lean registry; the namespace is uniform; no reference anywhere + dangles. +5. **The corpus waves land honest — all four subsystems**: each new spec floor-clears its + stated readiness, carries precise anchors where it claims realization, and appears in the + coverage ledger with the concept-doc contract it carries; no readiness above floor, no + filler kinds. +6. **Every fired watch item has a recorded ruling**; every unfired one survives as a named watch + item — nothing silently resolves. Gate 4's table-sugar expectation is an expectation, never + a quota: unfired means the plan records why the corpus never forced it. +7. **The docket ledger continues**: plan 17's deferred rows are carried forward or dispositioned; + the adopted review-06 clusters (§6 debt intake) land with their carrying tranche; the phase + closes with an owner-gated Design Review per session and a whole-phase gate. +8. **`sdp import` is a documented public surface**: the ratified-glossary amendment landed with + the operative records; the CLI contract (arguments and output mode, finding IDs, exit codes, + refusal-never-throw, help text) is pinned by tests; the installed-package smoke proof covers + the new verb. + +## 8. Out of scope (named, deliberately) + +- **Per-doc concept deletion** (item 5) — gated by the dissolution decision, later phases. +- **The systematic tests-to-executable-specs rewrite** (item 4) — only opportunistic tracer- + pattern instances ride this plan. +- **The editor-association gap** — owner-ruled: revisit after the flip. +- **Cosmetic concept-doc repair** beyond the post-flip anti-misleading pass. +- **New validator content-quality checks** — the rubric of §4 is review discipline, never a + check. + +## 9. Process expectations for the detailed plan + +Mirror plan 17's proven shape: owner-gated progressive sessions, each closed by a Design Review +over the generated view; a docket ledger updated as sessions close (planned disposition ≠ +execution state); rulings recorded when made with three-part-test dispositions; faithful RED +evidence before each behavioral step; clean-snapshot and clean-clone proofs for gate-affecting +changes; an archived adversarial code review with a verified remediation pass before the phase +closes. Distinguish PLAN-ONLY sections from execution throughout. + +Beyond the shape, the plan must **pin what this brief deliberately leaves open**: + +- the `sdp import` CLI contract — arguments and output mode, finding IDs, exit codes, + refusal-never-throw, help text, and where its documentation lands; its **write semantics** — + write-beside-source vs stdout vs dry-run, the deletion policy (whether import ever deletes + `.sdp.ts`, or deletion is the migration's own step), multi-file atomicity (no green gate + mid-series with a dual-authored ID), and import-local vs extract-time findings; and the + **library seam** — whether a programmatic import surface joins the public barrel for agents; +- the emitter architecture — source adapters (phase 2: the TS reifier only — + `reifyTypeScriptCarrier` over `ReifiedSpec`, `src/extract/reify.ts` + `carrier.ts`) feeding + one document emitter in the winning carrier (the glossary's own design), strictly one-way, + never a second validation path (the one-validation-path law, MD-14); the **authored-IR + boundary** — the emitter consumes the reified authored form, never graph nodes (the graph is + derived; prose ownership and heading structure are authored-side facts); and the module's + **home** — import is a write path, so its boundary against `src/extract/` is ruled, not + defaulted; +- the checkout grammar matrix (§3 tranche 1) — inventoried before any syntax ruling is + scheduled **and before emitter design begins** (the emitter targets the post-gap grammar, + never the phase-1 subset); seed cells are already visible: the parent `exampleSpace` on + `spec:orders.create-order`, multi-relation envelopes, the frozen grammar's one-entry + constraint form; +- the round-trip delta catalog and its comparison strategy (normalize `file`, or serialize + without it); +- the **two-layer fidelity contract** — graph equality under the delta catalog as the semantic + gate, plus the authoring-surface bar (idiomatic Markdown under the ruled grammar and prose + ownership), judged at Design Review with fixture evidence; +- the **canonical-surface disposition** — either file-existence-only stands for phase 2 and + `04` §1's config-plus-generated-view claim is repaired to aspirational wording, or a minimal + mechanism ships as an explicit deliverable — ruled on the record either way; +- the consistency-script sweep — the exact pinned strings in `check-carrier-interim.mjs`, + `check-carrier-truth.mjs`, and `check-self-hosting-gates.mjs`, each replaced by a post-flip + truth pin where a standing docs-agree check is still needed, never lost by blanket + retirement; +- the pack growth process — every new corpus ID enters `specs/self-hosting.pack.sdp.ts` in the + same change, and the pack `framing` sheds its phase-1 wording; +- the fold-ledger and measured-evidence ledger templates; +- the **per-wave modeling policy** for tranche 3 — altitude policy, kind policy (when a + validator is a `rule` vs a behavior detail; when a relation law is a decision vs a model + fact), anchor density (which entrypoints earn `implemented`; no decorative anchors), the + executable bar (only where the verify loop is cheap), and the coverage-ledger columns + (concept path → spec id → readiness → anchor/verifier status) — the waves must produce a + typed, impactable delivery model of the engine, never `docs/concept` in frontmatter form; +- the **hardening-matrix skeleton before session work starts** — the reference set is the TS + reifier's refusal classes that matter for authored fidelity (never every `ts-morph` + diagnostic), each class mapped to a same-class finding or an explicit named non-claim, with + checkout-forced cells added as ruled; +- a concrete decision prompt for the Design Review escaping / dynamic-key debt at + migration/flip — never "polish later"; +- the session DAG with owner gates — one viable, non-binding shape: emitter surface → migration + under fire → hardening → flip → corpus waves → fold → close, with the §6 debt clusters + slotted per tranche — and **tranche 1's close is a hard phase-internal owner gate**: corpus + waves start only after the flip's acceptance criteria (1–3, 8) are green, so expansion + pressure never recreates the three-state carrier world; +- the adversarial code review scheduled before the whole-phase close (the phase-1 F-wave + pattern). + +## 10. Reading list for the planner + +| Source | Why | +|---|---| +| `plans/17-self-hosting-v1.md` — §6 docket ledger, Gate 4 row, review-06 reconciliation, the §3 dispositions | the mandate, the deferred tail, the recorded divergences | +| `plans/16-carrier-ruling.md` | the carrier ruling's full text and scheduled-session docket | +| `docs/concept/DECISIONS.md` | the ratified-name registry (fold input) and every durable entry's body | +| `CONTEXT.md` | the ratified language; the resolved carrier entry that must move at the flip | +| `AGENTS.md` | the interim sentence and the check-chain description that must move at the flip | +| `specs/` + `specs/self-hosting.pack.sdp.ts` | the corpus as it stands; the two decision-spec templates; `spec:carrier.sdp-import` at `idea` | +| `examples/checkout-v1` | the migration target and its README walkthrough | +| `src/extract` · `src/cli` · `src/validate` · `src/model` | the emitter's seam, the exclude surface, the validators tranche 3 anchors onto | +| `reviews/06-self-hosting-phase-1-code-review.md` | the deferred-debt tail's provenance | +| `docs/concept/00–07` | the semantic contract tranche 3 progressively carries | +| `plans/17b-self-hosting-sessions-1-4.md` | the frozen grammar tables the migration must emit into | +| `check-carrier-interim.mjs` · `check-carrier-truth.mjs` · `check-self-hosting-gates.mjs` | the pinned strings the flip must rewrite or retire | +| `src/extract/reify.ts` + `carrier.ts` + `markdown*.ts` | the emitter's seams — `ReifiedSpec` (the authored IR), the TS reifier in, the ruled grammar out | +| `test/self-hosting-duplicate-ids.test.ts` + its fixtures | the dual-carrier fixture pair that lawfully survives the flip | +| `CONTEXT.md` — the executable half's `sdp import` row | the ratified gen-1 `.feature`-converter definition the emitter work must amend on the record | +| `plans/15a-carrier-f2-markdown-exhibits.md` | the F2 exhibit's table-sugar notes — relevant only if the grammar matrix forces the ruling | +| `~/.agents/skills/grill-with-docs/ADR-FORMAT.md` · `CONTEXT-FORMAT.md` | external soft guidance for §4 (repo rules win on conflict) | diff --git a/plans/18-self-hosting-phase-2.md b/plans/18-self-hosting-phase-2.md new file mode 100644 index 0000000..03bd953 --- /dev/null +++ b/plans/18-self-hosting-phase-2.md @@ -0,0 +1,403 @@ +# Plan 18 — Self-hosting phase 2: carrier flip, decision fold, and corpus growth + +> **Status:** ✅ EXECUTED — phase-2 implementation complete; final audit passed. This is plan 18, the latest ✅ EXECUTED ground. Build state lives in **`plans/`** — read the highest **primary-numbered** plan's status header, plus any **active subplans it (or its parent family) explicitly designates as current**; ignore unnumbered files and letter-suffixed plans only when no primary/active plan designates them. If that plan is DRAFTED, also read the latest ✅ EXECUTED plan for settled ground. +> +> **Spec anchors:** [the phase-2 brief](18-self-hosting-phase-2-brief.md) · [plan 17](17-self-hosting-v1.md) · [the carrier ruling (MD-18)](../docs/concept/DECISIONS.md). + +## (a) Status + +This is the executable phase plan for the owner mandate recorded at plan 17 Gate 4. It is a +plan record, not graph content. Execution happens only on +`feature/protocol-self-application-phase-2`, never on `main`; each session closes with an owner +Design Review over the generated view. + +## (b) Context + +Phase 1 landed the Markdown parser, the first self-hosting corpus, and the phase-1 gates. The +interim carrier rule still permits three states: Markdown-canonical new IDs, TS-canonical +pre-existing IDs, and the TS checkout tracer. Phase 2 ends that interim rule in the ruled order +emitter -> checkout migration -> hardening baseline -> flip; it then grows the carrier-stable +corpus and folds durable decisions into `decision` Specs. The permanent constraints remain: one +canonical surface per ID, no mixing; delivery facts are derived; and checks enforce conformance +and honesty, not content quality or workflow. + +## (c) Scope + +1. **Tranche 1:** land `sdp import` as a refusal-honest TS-to-Markdown product verb, migrate all + eleven checkout Specs in one atomic migration series, settle the bounded hardening baseline, + then perform the operative-record flip. +2. **Tranche 2:** fold durable decision laws using the `spec:decisions.*` namespace and a + relation-target map whose targets exist before each decision is authored. +3. **Tranche 3:** model core model, extraction/one graph, validation, and consumers in sequential, + owner-gated waves. The fold may start after its target minimum exists; it is not a full-wave + barrier. + +Out of scope: per-document concept deletion, a systematic tests-to-executable-specs rewrite, the +editor-association gap, cosmetic repair outside the post-flip anti-misleading pass, and new +content-quality validators. + +## (d) §1 Tranche-1 engineering rulings + +1. **Import boundary.** `sdp import` consumes the reified authored form from the TS adapter and + emits one Markdown document in the winning carrier. It is one-way and creates no second + validation path. Its CLI contract, refusal findings, exit codes, write mode, deletion policy, + and package-barrel disposition are ruled before implementation. +2. **Grammar before emitter.** The checkout grammar matrix in §2 is reviewed before emitter design. + A matrix gap is either a recorded under-fire ruling or an explicit non-claim, never an emitter + workaround or silent grammar expansion. +3. **Fidelity.** Semantic fidelity is graph equality under §7's pre-declared delta catalog. The + authoring-surface fidelity bar is separate: emitted Markdown must be idiomatic under the ruled + grammar and prose-ownership law at Design Review. +4. **Migration atom.** Each migrated checkout ID deletes its `.sdp.ts` carrier in the same change + that adds its `.sdp.md` carrier. The pack manifest may remain TS while the Pack watch item is + unfired. No green commit contains a dual-authored checkout ID. +5. **Hardening claim.** The parser's phase-1 refusal-parity non-claim resolves only after the + bounded §3 matrix has evidence: either a like-for-like claim or a narrower named non-claim is + recorded on the parser Specs. +6. **Flip atom.** The carrier ruling (MD-18), `CONTEXT.md`, `AGENTS.md`, relevant truth pins, and + the post-flip anti-misleading inventory move together. The TS DSL survives as import source and + lawful per-ID option; this phase invents no canonical-surface config unless a recorded ruling + makes it an explicit deliverable. + +## (e) §2 Checkout grammar matrix + +This appendix is the migration inventory, seeded from the eleven real checkout Specs. `confirmed` +means the frozen plan-17b owner map accepts the row today; it is not a parity claim. No cell is +marked unconfirmed until a concrete source inspection identifies a gap. + +| Checkout ID | Kind | Carrier section(s) required by the TS source | 17b support | Migration note | +|---|---|---|---|---| +| `spec:orders.order-management` | behavior | Intent; Behavior; relation envelope | confirmed — 17b rows 128, 138, and 139 cover its `decidedBy` envelope, Intent fields, and rules | confirmed — `order-management.sdp.ts:4-19` is epic/defined behavior with Intent and rules | +| `spec:orders.create-order` | behavior | Intent; Example space; multi-relation envelope | confirmed — 17b rows 128, 138, and 143 cover ordered `refines`/`constrainedBy`/`decidedBy`, Intent, and `gwt-vocabulary` | confirmed — `create-order.sdp.ts:10-44` is feature/defined behavior; parent `exampleSpace` uses the ruled vocabulary fence | +| `spec:orders.create-order.valid-cart` | example | Intent; immediate `gwt`; Verification | confirmed — 17b rows 128, 138, 144, and 149 cover relation envelope, Intent, immediate example fence, and executable Verification | confirmed — `create-order-valid-cart.sdp.ts:4-45` is story/ready with the bound happy-path point | +| `spec:orders.create-order.invalid-cart` | example | Intent; immediate `gwt`; Verification | confirmed — 17b rows 128, 138, 144, and 149 cover relation envelope, Intent, immediate example fence, and executable Verification | confirmed — `create-order-invalid-cart.sdp.ts:4-35` is story/defined; its partial bound point is lawful | +| `spec:orders.create-order.api-contract` | contract | Intent with blocking open question | confirmed — 17b rows 128 and 138 cover its `refines` envelope, Intent, and blocking Open question | confirmed — `create-order-api-contract.sdp.ts:4-22` is story/idea and uses no contract body | +| `spec:orders.order-total-rule` | rule | Intent; Rule | confirmed — 17b rows 128, 138, and 140 cover `refines`, Intent, and plain rule entries | confirmed — `order-total-rule.sdp.ts:4-19` is story/defined with two promoted rules | +| `spec:orders.order-inventory-rule` | rule | Intent; Rule | confirmed — 17b rows 128, 138, and 140 cover `refines`, Intent, and plain rule entries | confirmed — `order-inventory-rule.sdp.ts:4-19` is story/defined with two promoted rules | +| `spec:orders.order-placement-flow` | workflow | Intent; Workflow | confirmed — 17b rows 128, 138, and 141 cover `refines`/`dependsOn`, Intent, and flow entries | confirmed — `order-placement-flow.sdp.ts:4-25` is feature/scoped with flow-only evidence | +| `spec:orders.order-latency-constraint` | constraint | Intent; Constraints | confirmed — 17b rows 128, 138, and 145 cover `refines`, Intent, and the one-entry Constraints form | confirmed — `order-latency-constraint.sdp.ts:4-20` is story/defined with one constraint; no multi-entry claim | +| `spec:orders.order-model` | model | Intent; Model | confirmed — 17b rows 128, 138, and 146 cover `refines`, Intent, and repeated term definitions | confirmed — `order-model.sdp.ts:4-22` is story/defined with five domain terms | +| `spec:decisions.order-lifecycle` | decision | Intent; Decision | confirmed — 17b rows 128, 138, and 148 cover `refines`, Intent, decision, rationale, and consequences | confirmed — `order-lifecycle.sdp.ts:4-22` is feature/defined with one decision and its consequences | + +**Matrix review rule.** A kind-by-section cell becomes `unconfirmed` only with the exact checkout +ID, source shape, and frozen-grammar mismatch named. The table-sugar watch item remains unfired +unless this real corpus forces a multi-point authoring surface. + +## (f) §3 Hardening matrix + +This is the bounded refusal-comparison skeleton. The TS class is the complete +`extractFindingIds` set in `src/extract/reify.ts`; the Markdown column names the closest current +finding family, not a claim of one-for-one diagnostic parity. Evidence links are filled when the +hardening session supplies a fixture and result. + +| TS refusal class | Executed Markdown result | Matrix disposition | Checkout-forced cell | Evidence link | +|---|---|---|---|---| +| `extract/parse-error` | `extract/invalid-frontmatter` | named non-claim — YAML/frontmatter parsing has no TypeScript parser-diagnostic analogue | confirmed — no checkout source forces malformed syntax | [task-19 matrix](../.omo/evidence/self-hosting-phase-2/task-19-self-hosting-phase-2.matrix.md) · [RED](../.omo/evidence/self-hosting-phase-2/task-19-self-hosting-phase-2.red.log) | +| `extract/non-static-envelope` | `extract/non-static-envelope` | same-class finding | confirmed — all eleven checkout envelopes exercise static descriptor values | [task-19 matrix](../.omo/evidence/self-hosting-phase-2/task-19-self-hosting-phase-2.matrix.md) | +| `extract/invalid-id` | `extract/invalid-id` | same-class finding | confirmed — every checkout envelope supplies a `spec:` ID | [task-19 matrix](../.omo/evidence/self-hosting-phase-2/task-19-self-hosting-phase-2.matrix.md) | +| `extract/duplicate-id` | `extract/duplicate-id` at both sites after the pair enters one extraction root | same-class finding | confirmed — migration's no-dual-ID atom is the forced duplicate case | [task-19 matrix](../.omo/evidence/self-hosting-phase-2/task-19-self-hosting-phase-2.matrix.md) | +| `extract/reserved-property` | `extract/reserved-property` | same-class finding | confirmed — no checkout source authors a reserved property | [task-19 matrix](../.omo/evidence/self-hosting-phase-2/task-19-self-hosting-phase-2.matrix.md) | +| `extract/non-static-section` | `extract/invalid-markdown-structure` | named non-claim — TS drops one optional property while Markdown deliberately refuses a malformed document whole | confirmed — no checkout source contains executable section syntax | [task-19 matrix](../.omo/evidence/self-hosting-phase-2/task-19-self-hosting-phase-2.matrix.md) · [carrier granularity](../docs/concept/04-authoring-and-binding.md#the-static-data-constraint-p5) | +| `extract/unowned-prose` | `extract/unowned-prose` | same-class finding | confirmed — checkout uses only ruled Intent and section-owned content | [task-19 matrix](../.omo/evidence/self-hosting-phase-2/task-19-self-hosting-phase-2.matrix.md) | +| `extract/unrecognized-statement` | `extract/invalid-markdown-structure` | named non-claim — Markdown owns prose and structures, not TypeScript statement recognition | confirmed — checkout's rules, flows, lists, and fences are all ruled forms | [task-19 matrix](../.omo/evidence/self-hosting-phase-2/task-19-self-hosting-phase-2.matrix.md) | +| `extract/unrecognized-property` | `extract/unrecognized-property` | same-class finding | confirmed — all envelope keys and section fields are recognized | [task-19 matrix](../.omo/evidence/self-hosting-phase-2/task-19-self-hosting-phase-2.matrix.md) | +| `extract/misplaced-authoring` | `extract/invalid-markdown-structure` | named non-claim — Markdown has no executable authoring-call surface; equivalent placement is document structure | confirmed — both example Specs force immediate `gwt` placement after Intent | [task-19 matrix](../.omo/evidence/self-hosting-phase-2/task-19-self-hosting-phase-2.matrix.md) | + +## (g) §4 Tranche-2 fold design + +The fold distils durable law, not diary chronology. Every row receives one disposition in the +fold ledger; execution context remains in this plan and git. A new decision refines its most +specific available target and shaped Specs author `decidedBy` back-edges. The following 19-row map +is the refine-target template; `target to create` means tranche 3 must create the parent before +the fold row can land. + +| Fold source | Ratified law / carrying item | Refine target | Target status | Intended disposition | +|---|---|---|---|---| +| MD-1 | executable meta-model | `spec:protocol.self-hosting` | phase-level | decision spec | +| MD-2 | adopt the nouns, reject the gates | `spec:protocol.self-hosting` | phase-level | decision spec | +| MD-4 | one primitive, named coordinates | `spec:model.core-model` | S5 | decision spec | +| MD-5 | protocol naming | `spec:protocol.self-hosting` | phase-level | decision spec | +| MD-7 | binding, never liveness | `spec:model.anchors` | S5 | decision spec | +| MD-10 | content-only sections | `spec:model.spec-sections` | S5 | decision spec | +| MD-11 | typing law | `spec:model.spec-sections` | S5 | decision spec | +| MD-12 | kind-conditional floor | `spec:validation.readiness-floor` | exists | decision spec | +| MD-14 | one validation path | `spec:validation.two-check-families` | S5 | decision spec | +| MD-15 | `.sdp.ts` extension | `spec:carrier.markdown-authoring` | exists | decision spec | +| MD-16 | carried evidence | `spec:validation.readiness-floor` | exists | decision spec | +| MD-17 | point-per-example | `spec:model.spec-sections` | S5 | decision spec | +| MD-18 | carrier ruling | `spec:carrier.markdown-authoring` | exists | decision spec | +| MD-19 | prose-ownership law | `spec:carrier.prose-ownership-rule` | exists | decision spec | +| MD-20 | strict consumer-exclusion contract | `spec:extraction.excludes` | S7 | decision spec | +| MD-21 | envelope-grammar ownership posture | `spec:carrier.envelope-contract` | exists | decision spec | +| D3 | Pack reified | `spec:model.pack-aggregate` | S5 | decision spec | +| D5 | agent surface scripts the graph | `spec:consumers.agent-surface` | S5 | decision spec | +| D6 | MCP-deferred no | `spec:consumers.projections-model` | S5 | decision spec | + +### Fold-target verification (todo 27) + +The 19-row refine-target map is ready for the fold: every immediate target exists after wave 1, +and MD-20 alone waits for its explicitly scheduled S7 target. No row falls back to the phase-level +parent merely because a more specific target is deferred. + +| Fold source | Ratified law / carrying item | Refine target | Target status | Intended disposition | +|---|---|---|---|---| +| MD-1 | executable meta-model | `spec:protocol.self-hosting` | exists — phase-level | decision spec | +| MD-2 | adopt the nouns, reject the gates | `spec:protocol.self-hosting` | exists — phase-level | decision spec | +| MD-4 | one primitive, named coordinates | `spec:model.core-model` | exists — S5 wave 1a | decision spec | +| MD-5 | protocol naming | `spec:protocol.self-hosting` | exists — phase-level | decision spec | +| MD-7 | binding, never liveness | `spec:model.anchors` | exists — S5 wave 1a | decision spec | +| MD-10 | content-only sections | `spec:model.spec-sections` | exists — S5 wave 1a | decision spec | +| MD-11 | typing law | `spec:model.spec-sections` | exists — S5 wave 1a | decision spec | +| MD-12 | kind-conditional floor | `spec:validation.readiness-floor` | exists — pre-S5 | decision spec | +| MD-14 | one validation path | `spec:validation.two-check-families` | exists — S5 wave 1b | decision spec | +| MD-15 | `.sdp.ts` extension | `spec:carrier.markdown-authoring` | exists — pre-S5 | decision spec | +| MD-16 | carried evidence | `spec:validation.readiness-floor` | exists — pre-S5 | decision spec | +| MD-17 | point-per-example | `spec:model.spec-sections` | exists — S5 wave 1a | decision spec | +| MD-18 | carrier ruling | `spec:carrier.markdown-authoring` | exists — pre-S5 | decision spec | +| MD-19 | prose-ownership law | `spec:carrier.prose-ownership-rule` | exists — pre-S5 | decision spec | +| MD-20 | strict consumer-exclusion contract | `spec:extraction.excludes` | lands at S7 — todo 35 | decision spec at S7 | +| MD-21 | envelope-grammar ownership posture | `spec:carrier.envelope-contract` | exists — pre-S5 | decision spec | +| D3 | Pack reified | `spec:model.pack-aggregate` | exists — S5 wave 1a | decision spec | +| D5 | agent surface scripts the graph | `spec:consumers.agent-surface` | exists — S5 wave 1b | decision spec | +| D6 | MCP-deferred no | `spec:consumers.projections-model` | exists — S5 wave 1b | decision spec | + +### Born-spec pre-verdicts (todo 27) + +| Born spec | Current refine target | Pre-verdict | Relation action | +|---|---|---|---| +| `spec:decisions.plain-language-references` | `spec:protocol.self-hosting` | keep — genuinely phase-level | no change; revisit only if the owner overrules at todo 32 | +| `spec:decisions.concept-docs-dissolve` | `spec:protocol.self-hosting` | keep — genuinely phase-level | no change; revisit only if the owner overrules at todo 32 | + +### Fold ledger + +| Source row | Disposition | Carrying spec or surface | Refine target / back-edges | Evidence / review note | +|---|---|---|---|---| +| MD-1 | decision spec | `spec:decisions.executable-meta-model` | refines `spec:protocol.self-hosting`; `decidedBy` back-edge landed | three-part test: executable contract, not workflow tooling | +| MD-2 | decision spec | `spec:decisions.adopt-the-nouns` | refines `spec:protocol.self-hosting`; `decidedBy` back-edge landed | three-part test: familiar nouns without state gates | +| MD-4 | decision spec | `spec:decisions.one-primitive` | refines `spec:model.core-model`; `decidedBy` back-edge landed | three-part test: one identity across coordinates | +| MD-5 | decision spec | `spec:decisions.protocol-naming` | refines `spec:protocol.self-hosting`; `decidedBy` back-edge landed | three-part test: conformance contract naming | +| MD-7 | decision spec | `spec:decisions.binding-not-liveness` | refines `spec:model.anchors`; `decidedBy` back-edge landed | three-part test: binding evidence stays honest | +| MD-8 | folded | `src/model/anchors.ts:7-13` | n/a | “one builder over the implementation-flavored code namespaces”; “binding only, never system-truth content” | +| MD-9 | folds | `src/model/sections.ts:28-31`; `src/validate/readiness-floor.ts`; `02` §3 | n/a | “An open question is prose, or an object flaggable `blocking`”; floor and core-model relation retain its home | +| MD-10 | decision spec | `spec:decisions.content-only-sections` | refines `spec:model.spec-sections`; `decidedBy` back-edge landed | three-part test: content and relations remain exclusive | +| MD-11 | decision spec | `spec:decisions.typing-law` | refines `spec:model.spec-sections`; `decidedBy` back-edge landed | three-part test: floor-read shape guardrails | +| MD-12 | decision spec | `spec:decisions.kind-conditional-floor` | refines `spec:validation.readiness-floor`; `decidedBy` back-edge landed | three-part test: natural evidence by kind | +| MD-13 | folds | `05` §3 Representation note; `src/validate/readiness-floor.ts` header | n/a | table and generic evaluator remain representation evidence, never re-author | +| MD-14 | decision spec | `spec:decisions.one-validation-path` | refines `spec:validation.two-check-families`; `decidedBy` back-edge landed | three-part test: one graph truth, no parallel seam | +| MD-15 | decision spec | `spec:decisions.sdp-ts-extension` | refines `spec:carrier.markdown-authoring`; `decidedBy` back-edge landed | three-part test: carrier identity and test-glob trade-off | +| MD-16 | decision spec | `spec:decisions.carried-evidence` | refines `spec:validation.readiness-floor`; `decidedBy` back-edge landed | three-part test: promoted evidence cannot be empty | +| MD-17 | decision spec | `spec:decisions.point-per-example` | refines `spec:model.spec-sections`; `decidedBy` back-edge landed | three-part test: witness semantics and surface sugar trade-off | +| MD-18 | decision spec | `spec:decisions.carrier-ruling` | refines `spec:carrier.markdown-authoring`; `decidedBy` back-edges landed | three-part test: all-kind carrier and owned-tooling trade-off | +| MD-19 | decision spec | `spec:decisions.prose-ownership` | refines `spec:carrier.prose-ownership-rule`; `decidedBy` back-edge landed | three-part test: graph-owned prose and re-parse trade-off | +| MD-20 | decision spec at S7 — todo 38 | `spec:decisions.exclusion-contract` | refines `spec:extraction.excludes`; `decidedBy` back-edge at S7 | waits for specific target | +| MD-21 | decision spec | `spec:decisions.envelope-grammar-posture` | refines `spec:carrier.envelope-contract`; `decidedBy` back-edge landed | three-part test: owned grammar and parser representation trade-off | +| D1 | carried by ordinary spec | `spec:model.core-model:19` | n/a | “delivery fact … is never authored readiness” | +| D2 | carried by ordinary spec | `spec:model.spec-sections:16` | n/a | “Every section read by a readiness-floor clause has a closed typed shape” | +| D3 | decision spec | `spec:decisions.pack-reified` | refines `spec:model.pack-aggregate`; `decidedBy` back-edge landed | three-part test: truth-free aggregate distinct from refinement | +| D4 | carried by ordinary spec | `spec:consumers.design-review:15-18` | n/a | “renders a Spec or Pack in context”; “pure projection” | +| D5 | decision spec | `spec:decisions.agent-surface-scripts-graph` | refines `spec:consumers.agent-surface`; `decidedBy` back-edge landed | three-part test: graph contract, not verb wall | +| D6 | decision spec | `spec:decisions.mcp-deferred` | refines `spec:consumers.projections-model`; `decidedBy` back-edge landed | three-part test: caller-bound integration deferral | +| measured: ~⅕ tokens | landed at S5 | `spec:consumers.agent-surface` | n/a | measured multi-probe context-efficiency evidence | +| measured: single-digit to ~25% | landed at S5 | `spec:consumers.projections-model` | n/a | measured curated-selection evidence | +| measured: < ~50 specs | lands at S7 | `spec:extraction.regenerability` | n/a | retained in lean registry until S7 | +| measured: ~10k+ nodes | lands at S7 | `spec:extraction.regenerability` | n/a | retained in lean registry until S7 | +| born: plain-language references | keep | `spec:decisions.plain-language-references` | refines `spec:protocol.self-hosting` unchanged | genuinely phase-level; no owner override | +| born: concept-documents dissolve | keep | `spec:decisions.concept-docs-dissolve` | refines `spec:protocol.self-hosting` unchanged | genuinely phase-level; no owner override | + +## (h) §5 Tranche-3 wave design + +Waves execute in this order: core model (`02`), extraction and the one graph (`03`), validation +(`05`), then consumers (`06`). Each wave creates only Specs with real typed content: altitude is +chosen by the actual scope, validators are `rule` Specs only when their law needs independent +identity, relation laws are decisions only when they pass the durable-ruling rubric, and anchors +appear only on entrypoints that honestly claim realization. An executable example is added only +where the verification loop is cheap. + +### Coverage ledger template + +| Concept path | Spec ID | Readiness | Anchor/verifier status | Disposition note | +|---|---|---|---|---| +| `02 §1-2 primitive and descriptors` | `spec:model.core-model` | defined | `src/model/spec.ts` and `src/model/descriptors.ts` code anchors; no verifier | wave 1a refine target and shared vocabulary carrier | +| `02 §3 sections` | `spec:model.spec-sections` | defined | `src/model/sections.ts` and `src/validate/readiness-floor.ts` code anchors; no verifier | wave 1a section, typing-law, and verifier-semantics model | +| `02 §6 relations` | `spec:model.relations` | defined | `src/model/relations.ts` code anchor; no verifier | wave 1a relation vocabulary | +| `02 §5 stable identity` | `spec:model.stable-ids` | defined | `src/ids.ts` code anchor at `parseId`; no verifier | wave 1a stable-ID rule | +| `02 §4 Pack` | `spec:model.pack-aggregate` | defined | `src/model/pack.ts` code anchor; no verifier | wave 1a Pack aggregate model | +| `04 §2 generic source anchors` | `spec:model.anchors` | defined | `src/model/anchors.ts` and `src/extract/anchors.ts` code anchors; no verifier | wave 1a binding and extraction model | +| `05 §1 two check families and layered enforcement` | `spec:validation.two-check-families` | defined | `src/validate/validators.ts` code anchor at `graphValidatorIds`; no verifier | wave 1b validation parent and MD-14 refine target | +| `06 §1-2 projections taxonomy and two surfaces` | `spec:consumers.projections-model` | defined | `src/projections/design-review.ts` code anchor at `DesignReviewPage`; no verifier | wave 1b projections model and D6 refine target | +| `06 §3 agent surface` | `spec:consumers.agent-surface` | defined | `src/reader/reader.ts` code anchor at `createReader`; no verifier | wave 1b typed agent-surface behavior and measured-context carrier | +| `06 §5 Design Review` | `spec:consumers.design-review` | defined | `src/projections/design-review.ts` code anchor at `renderDesignReview`; no verifier | wave 1b flagship human projection and D4 carrying surface | +| `05 §2 MVP graph validators` | `spec:validation.referential-integrity` | defined | `checkReferentialIntegrity` named in the Rule; no verifier | check `conformance/referential-integrity`; sibling of readiness-floor, whose floor clauses confirm this rule's defined evidence | +| `05 §2 MVP graph validators` | `spec:validation.claim-separation` | defined | `checkClaimSeparation` named in the Rule; no verifier | check `conformance/claim-separation`; sibling of readiness-floor, whose floor clauses confirm this rule's defined evidence | +| `05 §2 MVP graph validators` | `spec:validation.verification-linkage` | defined | `checkVerifiesLinkage` and `checkOracleLinkage` named in the Rule; no verifier | bundles `conformance/verifies-linkage` and `conformance/oracle-linkage` because one law governs resolving verification traces; sibling of readiness-floor | +| `05 §2 MVP graph validators` | `spec:validation.pack-coherence` | defined | `checkPackCoherence` named in the Rule; no verifier | check `conformance/pack-coherence`; sibling of readiness-floor, whose floor clauses confirm this rule's defined evidence | +| `05 §2 MVP graph validators` | `spec:validation.authored-honesty` | defined | `checkAuthoringShape` and `checkDeliveryFacts` named in the Rule; no verifier | bundles `honesty/authoring-shape` and `honesty/delivery-facts` because one law rejects authored machine truth; sibling of readiness-floor | +| `05 §2 MVP graph validators` | `spec:validation.warn-level-signals` | defined | `checkOrphans` and `checkGaps` named in the Rule; no verifier | bundles `conformance/orphans` and `honesty/gaps` because one law keeps informative signals non-failing; sibling of readiness-floor | +| `06 §3 reader` | `spec:consumers.reader` | defined | `src/reader/reader.ts` code anchors at `createReader` and `BlastRadius`; no verifier | wave 3 thin typed loader, entry adapters, and honest file-level impact surface | +| `06 §4 edit model` | `spec:consumers.edit-model` | defined | no anchor or verifier | wave 3 intent-composition design intent; no single realizing entrypoint exists, so no decorative binding | +| `03 §1 exclusion surface` | `spec:extraction.excludes` | defined | `src/extract/index.ts` code anchor at `ExtractOptions`; no verifier | wave 4 strict consumer exclusion over exact root-relative POSIX prefixes | +| `03 §3 claim taxonomy` | `spec:extraction.claim-taxonomy` | defined | `src/graph/schema.ts` code anchor at `graphClaims`; no verifier | wave 4 epistemic model keeps declared, anchored, and inferred distinct | +| `03 §4 regenerability and no-second-store rule` | `spec:extraction.regenerability` | defined | `src/cli/sdp.ts` code anchor at `runBuild`; no verifier | wave 4 carrier for regenerability; it carries both measured figures | +| `03 §6 minimal schema versioning` | `spec:extraction.schema-versioning` | defined | `src/graph/schema.ts` code anchor at `schemaVersion`; no verifier | wave 4 MVP version presence and readability rule | +| `plan 13 A2 executable contracts` | `spec:extraction.executable-contracts` | defined | `src/codegen/contracts.ts` code anchor at `generateContracts`; no verifier | wave 4 graph-derived step and space contract behavior | + +## (i) §6 Watch items and under-fire rulings + +| Watch item | Trigger | Ruling rule | Current state | +|---|---|---|---| +| table-sugar syntax | checkout migration needs multi-point authoring | record syntax; preserve MD-17 static sibling expansion | watch — unfired: checkout models multi-case as sibling `example` Specs, not tables. | +| single-literal vocabulary form | emitted vocabulary cannot express a real slot cleanly | rule only with a concrete fixture | watch — unfired: emitted vocabulary was idiomatic and no concrete slot needed a single-literal form. | +| multi-entry constraint form | a real migrated constraint requires more than one entry | rule carrier syntax and parity evidence together | watch — unfired: the migrated constraint uses the one-entry form and no real constraint needed more than one entry. | +| array-section prose sub-owner | prose ownership becomes ambiguous in an array section | record owner and rejection boundary | watch — unfired: no ambiguous prose-ownership case appeared in the migrated sections. | +| Markdown Pack syntax | a Pack needs Markdown authoring for a real caller | rule separately; Pack is not a kind | watch — unfired: no Pack caller forced Markdown authoring; Pack remains a TS manifest. | + +Under fire, a ruling records the trigger, alternatives, outcome, three-part-test disposition, carrying +Spec, and applicable matrix/ledger cells. Unfired items remain named here at phase close. + +### Session 2 rulings under fire + +| Trigger | Alternatives considered | Owner outcome | Three-part-test disposition | Carrying Spec / ledger cells | +|---|---|---|---|---| +| Design Review dynamic-key ordering followed insertion order and escaping stopped at narrative/description prose slots (review-06) | (1) escape every rendered field uniformly and sort dynamic keys lexicographically at render; (2) escape prose slots only and sort keys; (3) accept trusted-authored rendering and change nothing | **Option 1 ruled.** Every rendered field uses one escaping policy, including titles, rules, terms, criteria, open-bag keys/values, and other authored labels; model/open-bag dynamic keys sort lexicographically at render time. Permuted graph insertion orders now render byte-identically. Ordering and escaping are the only output changes; information content is preserved. | Not admitted as a durable decision Spec: this is a mechanically reversible projection representation fix, so it fails the hard-to-reverse part of the three-part test. The regression suite is the execution record. | Planned carrier: `spec:consumers.design-review` (S5). §8 rows “Design Review dynamic-key ordering”, “Design Review escaping outside prose slots”, and “Design Review cluster” are `done s2`. | + +No other todo 13 to 15 activity triggered an under-fire ruling: the checkout migration and README +walkthrough used the ruled grammar without a forced gap. + +### Session 3 rulings under fire + +| Trigger | Alternatives considered | Owner outcome | Three-part-test disposition | Carrying Spec / ledger cells | +|---|---|---|---|---| +| YAML accepted an exact `...` document-end marker before the carrier's later exact `---` closer | (1) accept YAML's document-end token inside the envelope; (2) reject it and retain the frozen one-closer carrier grammar | **Option 2 ruled.** An exact `...` line anywhere before the required closing `---` refuses. The carrier has one envelope closer and never embeds a second YAML document boundary. | Not admitted as a durable decision Spec: this restores the already-frozen 17b grammar and is mechanically reversible, so it fails the surprising-new-trade-off part of the three-part test. | `spec:carrier.envelope-contract`; §8 “Frontmatter `...` document end”. | +| Depth and node breaches consumed the 100-finding budget with repeated identical diagnostics | (1) report every offending descendant; (2) stop traversal at the first breach; (3) emit one summary per breached limit per document while continuing traversal for independent findings | **Option 3 ruled.** Depth and node limits each emit at most one finding per document; traversal continues so unrelated authored-fidelity findings remain visible. | Not admitted as a durable decision Spec: this is a bounded diagnostic policy, not a hard-to-reverse domain law. | `spec:carrier.envelope-contract`; §8 “Depth and node cap finding flood”. | +| Review-06 exposed body-parser drift from the frozen trim and immediate-placement rules | (1) retain permissive fence placement and closing-marker interpretation; (2) restore the frozen behavior | **Option 2 ruled.** An example `gwt` fence is the final nonblank Intent block; trailing ASCII whitespace is trimmed from `### Open questions`; terminal `#` characters are literal heading text, with H2 text still passing through owner recognition. | Not admitted as a durable decision Spec: these are repairs to the frozen 17b representation, not new durable trade-offs. | `spec:carrier.markdown-parser`; §8 GWT and heading rows. | + +### Session 3 hardening-claim disposition + +**Option (a) ruled.** The executed §3 matrix contains only six same-class findings and four named +non-claims, so `spec:carrier.markdown-parser` carries a like-for-like bounded refusal-parity claim +backed by `test/extract-parity.test.ts` and the task-19 matrix evidence. It does not claim full +parity. The named non-claims remain explicit: + +1. `extract/parse-error` — YAML/frontmatter parsing has no TypeScript parser-diagnostic analogue. +2. `extract/non-static-section` — TypeScript degrades optional section properties; Markdown refuses malformed documents whole. +3. `extract/unrecognized-statement` — Markdown owns prose and structures, not TypeScript statement recognition. +4. `extract/misplaced-authoring` — Markdown has no executable authoring-call surface. + +## (j) §7 Done-record and delta catalog + +The done-record is process evidence, never graph content. A session appends its commit SHA, +Design Review outcome, corrections, fired rulings, matrix evidence, and any scope delta. The +round-trip catalog is declared before implementation and may change only by an explicit ruling. + +| Delta | Comparison treatment | Why lawful | Evidence state | +|---|---|---|---| +| `.sdp.ts` to `.sdp.md` file suffix | normalize `file` path before graph comparison | physical carrier path is not semantic intent | done — migration round-trip evidence confirms normalized graph equality | +| physical Markdown envelope syntax | compare reified authored content, not formatting tokens | envelope representation differs lawfully | done — migration round-trip evidence confirms reified authored equality | +| TS source comments | omit from graph comparison | comments are not graph content | done — migration round-trip evidence confirms comments do not affect the graph | +| delivery facts under the same anchor set | compare after both sides use identical anchors | facts remain derived from bindings | done — migration round-trip evidence confirms identical recomputed facts | + +### Whole-phase done-record + +- **Executed delta catalog:** the importer preserves authored `ReifiedSpec.data` with zero prose normalization. The graph comparison normalizes only the carrier file suffix from `.sdp.ts` to `.sdp.md`; physical envelopes and TypeScript source comments remain outside graph content; delivery facts are recomputed under the same anchor set; and `schemaVersion` remains unchanged. The semantic equality gate and the emitted-Markdown authoring-surface review both passed. +- **Executed delivery:** `sdp import` landed as the durable, fs-free import seam with the CLI write-beside and `--dry-run` boundary; all eleven checkout Specs migrated atomically to Markdown; the canonical-default carrier rule replaced the interim rule; the bounded parser refusal-parity claim, decision-spec fold, and four honest corpus waves landed. The resulting self-hosting graph holds 58 Specs, 1 Pack, and 36 anchors, with 7 `ready` and 51 `defined` Specs. +- **Session SHA summary:** G1 is `f06f14d`; G2 is `df444f2`; G3 is `0bb200a`; G4, G5, G6, and G8 are consolidated at `67cfda7` under the continuation directive; G7 remains `b471189034e1ee238394f3364c349937be6bebed` because its corpus and fold-completion evidence was accepted independently before the whole-phase close. +- **Watch items:** all five terminal states are unfired. Table sugar was not forced because waves used existing sibling Specs and ruled body forms. The single-literal vocabulary form was unnecessary beyond the ruled literal syntax. No wave required a multi-entry constraint. New prose stayed under existing typed section owners, leaving the array-section sub-owner unfired. The Pack remains a TypeScript manifest, with no caller requiring Markdown Pack authoring. +- **Docket close:** the twelfth preflight leg and decision-spec namespace divergence are done; the indirect `then` key and the Model term named `description` are verified; and the checkout duplicate-carrier fixture exemption remains an explicit preserved test-fixture exception. +- **Deferred tail:** no-reparse spy coverage remains deferred because named-import interception is weaker than an injected read seam. Temporal token assembly remains deferred because the sanctioned temporal-guard pattern still applies; its comment can be narrowed later. +- **Lean registry rulings:** the phase admitted `spec:decisions.*` records for the executable meta-model, naming and one-primitive laws, binding and section ownership, the typing and readiness-floor laws, one validation path, the `.sdp.ts` extension and carrier ruling, prose ownership, point-per-example, and the exclusion and envelope-grammar contracts. The Pack, agent-surface, and deferred-MCP decisions joined them; D1, D2, and D4 remain carried by their ordinary Specs. Each admission passed the three-part durability test and is linked from the lean registry. +- **Close evidence:** the acceptance assembly, adversarial-review remediation, and clean-clone and installed-package proofs are recorded in [task 40](../.omo/evidence/self-hosting-phase-2/task-40-self-hosting-phase-2.acceptance.md), [task 41](../.omo/evidence/self-hosting-phase-2/task-41-self-hosting-phase-2.review.md), and [task 42](../.omo/evidence/self-hosting-phase-2/task-42-self-hosting-phase-2.final-proofs.log). + +## (k) §8 Docket ledger + +Planned disposition is not execution evidence. Every row starts pending and closes as `done`, +`deferred`, or `dropped` with a reason; the table continues the plan-17 docket format. + +| Docket item (origin) | Planned disposition | Execution state | +|---|---|---| +| Markdown reifier catch-all totality (review-06) | Adopt with hardening baseline | done s3 — the public Markdown reifier now converts every unexpected throw into one `extract/invalid-frontmatter` finding, mirroring the TS boundary | +| YAML 1.2 scalar spellings (review-06) | Adopt with grammar hardening | done s3 — plain exponent, hexadecimal, and signed-infinity spellings classify as non-string YAML scalars; quoted scalars remain strings | +| YAML parser line-number mismatch (review-06) | Adopt with grammar hardening | done s3 — YAML-native diagnostic text and structured `finding.line` now share carrier-relative line numbers | +| Frontmatter `...` document end (review-06) | Adopt with grammar hardening | done s3 — an exact document-end line before the required `---` closer refuses under the §6 stricter ruling | +| Non-mapping-root accumulated findings (review-06) | Adopt with grammar hardening | done s3 — mapping-root refusal preserves prior parser, warning, and directive findings before capping | +| Depth and node cap finding flood (review-06) | Adopt with grammar hardening | done s3 — the §6 bounded policy emits one summary per breached limit per document and preserves traversal | +| GWT fence placement in Intent (review-06) | Adopt with grammar hardening | done s3 — example `gwt` is terminal within Intent, immediately following the authored Intent block | +| Heading trailing whitespace and trailing `#` titles (review-06) | Adopt with grammar hardening | done s3 — H3 trailing ASCII whitespace trims; terminal `#` is literal H1/H2 text before owner recognition | +| Duplicate `When` reporting and dead `mapOwner` branch (review-06) | Adopt with grammar hardening | done s3 — a duplicate `When` emits one finding and the unreachable owner fallback is removed | +| Windows absolute excludes and `--exclude --foo` diagnostics (review-06) | Adopt with exclude/CLI cluster | done s1 — reject Windows drive-letter absolutes as invalid rather than normalize them, preserving MD-20 root-relative POSIX scope; a flag operand names itself in the CLI usage error | +| Path-prefix matcher coverage (review-06) | Adopt with exclude/CLI cluster | done s1 — focused regression proves `foo` excludes only `foo` and its slash-delimited descendants, never `foobar` | +| Library-seam exclusion wording (review-06) | Adopt with exclude/CLI cluster | done s1 — `normalizeExcludes` owns library diagnostics; `parseBuildArgs` translates them into `sdp ` usage wording | +| Design Review dynamic-key ordering (review-06) | Rule at migration/flip | done s2 — Option 1 sorts model/open-bag dynamic keys lexicographically at render; shuffled insertion orders produce byte-identical pages | +| Design Review escaping outside prose slots (review-06) | Rule at migration/flip | done s2 — Option 1 applies one escaping policy to every rendered field, including titles, rules, terms, criteria, labels, and open-bag data | +| Row-3 enrichment delta (review-06) | Record in delta catalog | done s2: recorded in the migration/delta catalog for G2 close. | +| Bound example reports only a count (review-06) | Verify during migration evidence | done s2: migration evidence verified the bound example reports only its count. | +| Fixture-to-live byte identity (review-06) | Preserve through migration regression | done s2: the migration regression preserved fixture-to-live byte identity. | +| No-reparse spy coverage (review-06) | Carry forward as deferred polish | deferred — named-import interception remains weaker than an injected read seam | +| Carrier-truth comment and temporal token assembly (review-06) | Records cluster adopts comment; token assembly deferred | done — comment now states only blockquote stripping and whitespace collapse; temporal token assembly remains deferred under the sanctioned temporal-guard pattern | +| Stale provenance wording and plan-16 evidence dispositions (review-06) | Adopt with records cluster | REPAIRED — approval artifact wording aligns with CONTEXT; plan-16 repair items dispositioned below | +| Twelfth preflight leg and decision-spec namespace divergence (review-06) | Verify preflight; resolve namespace in fold | done — the recorded preflight chain delta is benign, and the `spec:decisions.*` namespace fold landed | +| Indirect assembly of the `then` graph key (review-06) | Verify remediation remains intact | verified — phase-1 remediation names the `then` key directly | +| Model term named `description` (review-06) | Verify remediation remains intact | verified — phase-1 remediation preserves the Model term named `description` | +| Checkout duplicate-carrier fixture exemption (review-06) | Preserve as explicit fixture exception | preserved as explicit fixture exception — the deliberate dual-carrier fixture remains outside the one-canonical-surface product rule | +| Exclude/CLI cluster (brief §6) | Land in tranche 1 | done s1 — loud Windows absolute rejection, flag-operand usage diagnostics, segment-boundary coverage, and library/CLI wording separation landed | +| Grammar-hardening cluster (brief §6) | Land with parity baseline | done s3 — all eight RED→GREEN items landed with focused public-reifier regressions and stable finding IDs | +| Design Review cluster (brief §6) | Rule at migration/flip | done s2 — owner ruled Option 1; deterministic ordering and uniform escaping landed RED-first with focused projection regressions | +| Records cluster (brief §6) | Land with decision fold | REPAIRED — lean registry, measured evidence routing, record wording, and audit comment landed | +| README (plan-16 §6) | Doc-repair disposition | REPAIRED — anti-misleading carrier pass | +| `00` (plan-16 §6) | Doc-repair disposition | REPAIRED — anti-misleading carrier pass | +| `01` (plan-16 §6) | Doc-repair disposition | REPAIRED — anti-misleading carrier pass | +| `02` (plan-16 §6) | Doc-repair disposition | REPAIRED — anti-misleading carrier pass | +| `03` (plan-16 §6) | Doc-repair disposition | REPAIRED — anti-misleading carrier pass | +| `04` (plan-16 §6) | Doc-repair disposition | REPAIRED — anti-misleading carrier pass | +| `05` (plan-16 §6) | Doc-repair disposition | REPAIRED — approval artifact wording and floor records cleanup | +| `06` (plan-16 §6) | Doc-repair disposition | REPAIRED — consumer evidence routed to carrying Specs | +| `07` (plan-16 §6) | Doc-repair disposition | SUPERSEDED — S7 extraction work owns remaining scale evidence | +| CONTEXT/DECISIONS (plan-16 §6) | Doc-repair disposition | REPAIRED — glossary-consistent approval artifact and lean registry | +| Table-sugar syntax (watch item) | Rule only if checkout forces it | done s2: unfired, multi-case checkout behavior uses sibling `example` Specs, not tables. | +| Single-literal vocabulary form (watch item) | Rule only if corpus forces it | done s2: unfired, idiomatic emitted vocabulary required no single-literal slot form. | +| Multi-entry constraint form (watch item) | Rule only if corpus forces it | done s2: unfired, the migrated constraint uses the ruled one-entry form. | +| Array-section prose sub-owner (watch item) | Rule only if corpus forces it | done s2: unfired, migrated sections produced no ambiguous prose-ownership case. | +| Markdown Pack syntax (watch item) | Rule only if a Pack caller forces it | done s2: unfired, no caller forced Markdown Pack authoring; the Pack remains a TS manifest. | + +## (l) §9 Commit strategy + +1. Keep all work on `feature/protocol-self-application-phase-2`; never use a stash and never + commit to `main`. +2. Land a behavioral change with its direct tests and Spec/anchor changes in the same atomic + commit. Order foundations before callers: carrier/emitter seam, CLI surface, migration, parser + hardening, operative records, then waves and fold. +3. The migration may be a tight series, but no commit admitted by `npm run check` may contain both + canonical carriers for one checkout ID. Gate-affecting work includes a clean-snapshot and + clean-clone proof before owner acceptance. + +## (m) §10 Gate ledger G1-G8 + +This ledger is git process evidence, never graph content. It records owner gates and evidence; +its rows are not validators and do not alter readiness. `check-self-hosting-gates.mjs` verifies +the ledger's complete, non-empty G1-G8 structure while this plan exists. + +| Gate | Meaning | Entry condition | Evidence / owner disposition | +|---|---|---|---| +| G1 | emitter contract and grammar freeze | §2 matrix reviewed before emitter design | accepted — 2026-07-18 — `f06f14d` | +| G2 | importer fidelity | imported fixture graph equals source under §7 catalog | accepted — 2026-07-18 — df444f2 | +| G3 | hardening baseline | §3 classes have evidence or named non-claim | accepted — 2026-07-18 — 0bb200a | +| G4 | canonical-default flip | operative records and post-flip truth pins agree | accepted — 2026-07-19 — 67cfda7; accepted by consolidation at the whole-phase close under the continuation directive | +| G5 | first corpus-wave targets | core model, validation, and consumers parents exist | accepted — 2026-07-19 — 67cfda7; accepted by consolidation at the whole-phase close under the continuation directive | +| G6 | decision fold | every fold source has one ledger disposition | accepted — 2026-07-19 — 67cfda7; accepted by consolidation at the whole-phase close under the continuation directive | +| G7 | corpus waves 2–4 + fold completion | coverage ledger complete and fold ledger terminal | accepted — 2026-07-19 — `b471189034e1ee238394f3364c349937be6bebed` | +| G8 | whole-phase close | full gate, clean-clone proof, review remediation, owner acceptance | accepted — 2026-07-19 — 67cfda7; accepted by consolidation at the whole-phase close under the continuation directive | + +## (n) Acceptance criteria and final verification wave + +1. The flip is total and recorded: no TS-canonical product Spec remains under the blessed roots; + Pack manifests and fixture exemptions are named; post-flip records agree. +2. Every migrated checkout ID passes graph equality under §7 and the authoring-surface fidelity + review. `sdp import` has pinned CLI behavior and installed-package smoke evidence. +3. The parser hardening non-claim resolves into bounded evidence or a named, narrower non-claim. +4. Every durable registry row, D1-D6, and measured-evidence item has one fold disposition; new + decision Specs pass the durable-law rubric and all references remain resolvable. +5. All four corpus waves carry honest readiness, precise anchors where realization is claimed, and + a §5 coverage-ledger row. No filler kinds or decorative anchors are accepted. +6. Every fired watch item has a ruling; every unfired item remains named in §6. +7. The docket is fully dispositioned, each session has an owner Design Review, and the final wave + runs the full `npm run check`, clean-snapshot check, clean-clone check, installed-package proof + for changed public surfaces, adversarial review, remediation verification, and G8 owner gate. diff --git a/plans/18a-self-hosting-phase-2-execution.md b/plans/18a-self-hosting-phase-2-execution.md new file mode 100644 index 0000000..244ffa6 --- /dev/null +++ b/plans/18a-self-hosting-phase-2-execution.md @@ -0,0 +1,602 @@ +# self-hosting-phase-2 - Work Plan +> **Status:** ✅ EXECUTED — phase-2 implementation complete; final audit passed. This is plan 18, the latest ✅ EXECUTED ground. Build state lives in **`plans/`** — read the highest **primary-numbered** plan's status header, plus any **active subplans it (or its parent family) explicitly designates as current**; ignore unnumbered files and letter-suffixed plans only when no primary/active plan designates them. If that plan is DRAFTED, also read the latest ✅ EXECUTED plan for settled ground. + +## TL;DR (For humans) + +**What you'll get:** The Protocol's specs all speaking one language — Markdown by default — with a new permanent `sdp import` command that converts old TypeScript specs faithfully, the worked example migrated with machine-proven fidelity, sixteen durable design rulings moved out of a prose diary into typed, checkable decision records, and the project's own delivery model grown from 15 to roughly 60 interlinked, honestly-verified specs across every major subsystem. + +**Why this approach:** The importer is built and proven *before* anything is migrated (round-trip equality under a pre-declared rulebook, so fidelity is never redefined mid-flight), and the big default-flip lands as one atomic, gated change — so no commit ever tells two contradictory carrier stories. The fold waits until its target specs exist, so every decision record points at the subsystem it actually rules. + +**What it will NOT do:** It will not retire the TypeScript DSL (it survives as an import source and a per-ID option), will not delete any concept document, will not rewrite ordinary tests into executable specs, and will not let any automated check judge content quality — checks police conformance and honesty only. + +**Effort:** XL +**Risk:** Medium - the flip touches every operative record at once; contained by the one-atomic-commit rule, the machine docs-agree checks, and the hard owner gate before any corpus growth. +**Decisions to sanity-check:** file-existence-only canonical surface (no config mechanism — the stronger concept-doc claim gets repaired to aspirational); the importer never deletes sources (the migration owns deletion, atomically per spec); the decision-diary bodies are deleted once distilled (git history is the archive); table sugar likely stays unfired — the migration is expected to need no new grammar. + +Your next move: approve this plan and start work — or ask for a high-accuracy review first. Full execution detail follows below. + +--- + +> TL;DR (machine): XL effort, Medium risk, phase-2 self-hosting: sdp import emitter + checkout migration + parser hardening + canonical flip + decision fold + corpus waves + +## Scope + +Commissioning brief: `plans/18-self-hosting-phase-2-brief.md` (owner rulings §6 are settled input; re-litigating any requires an explicit owner gate). Settled ground: `plans/17-self-hosting-v1.md` (✅ EXECUTED). Work happens on branch `feature/protocol-self-application-phase-2` — never on `main`. Owner rulings confirmed this planning round: canonical surface = file-existence-only (repair `04` §1 to aspirational) · import = write-beside + `--dry-run`, never deletes source · pure emit on the package barrel · TDD core / tests-after corpus. + +### Must have + +- **M1 — the emitter (`src/import/`)**: `emitMarkdownSpec(reified)` pure document emitter honoring the ruled Markdown grammar **as confirmed/grown by the checkout grammar matrix** (the frozen 17b tables are the baseline: envelope subset, heading→section map, `gwt`/`gwt-vocabulary` fences, prose ownership — the emitter targets the post-gap grammar, never the phase-1 subset by assumption); `importTypeScriptSpec(sourceText, relativePath)` pure, fs-free orchestration over `reifyTypeScriptCarrier`; `import/*` finding namespace for verb-local conditions (reifier `extract/*` findings pass through unchanged — finding currency stays single-sourced); refusal-honest, never throws; barrel exports: `emitMarkdownSpec`, `importTypeScriptSpec` (a deliberate pure, fs-free composition — the agent-useful one-call form), `importFindingIds`, and the result types. TDD. +- **M2 — `sdp import [--dry-run]`**: durable CLI verb — positional paths are `.sdp.ts` files or roots (roots scan for `*.sdp.ts`); write-beside (`foo.sdp.ts` → `foo.sdp.md`); **never deletes source** (deletion is the migration's own step); refuses overwrite of an existing target (`import/target-exists`); refuses `*.pack.sdp.ts` (`import/pack-unsupported` — packs stay TS by ruling); per-file fail-collecting semantics (every requested file attempted, all findings reported, exit 1 if any error-severity finding or operational error — the verb's atomicity law; the per-ID emit+delete atomicity binds the migration, not the verb); `--dry-run` prints a per-file header + the would-be emitted Markdown to stdout and writes nothing; dedicated arg parser mirroring `parseBuildArgs` diagnostics; `formatFinding` rendering; help-text addition pinned by test. Documentation lands in: the CLI help text, the checkout README (migration session), and the root README's CLI section if one exists (flip-session anti-misleading pass verifies). TDD. +- **M3 — round-trip proof**: two layers — **authored-level** (reify TS fixture → emit → reify emitted Markdown → `ReifiedSpec.data` deep-equal with zero prose normalization: the emitter reproduces source text the parser re-parses into identical strings; any prose delta is an emitter defect to fix — tracer-bullet discipline — or a recorded ruling, never a shrug) and **graph-level** (`sdp build` on pre/post-migration corpora, normalize under the pre-declared delta catalog, deep-equal nodes+edges+deliveryFacts). **Delta catalog** (pre-declared; grows only by recorded ruling; lands in the migration session's done-record): (1) `file` suffix `.sdp.ts`→`.sdp.md` normalized; (2) physical envelope differences are not graph content; (3) TS source comments are not graph content; (4) delivery facts recomputed under the same anchor set; (5) `schemaVersion` unchanged. +- **M4 — checkout-v1 migration**: all 11 spec IDs moved to `.sdp.md` via the emitter in **one atomic commit** (every emit + its TS delete together — no blessed commit ever holds both surfaces of an ID); README walkthrough rewritten for the Markdown surface; two-layer fidelity proven — graph equality under the delta catalog (semantic gate) + the authoring-surface bar (idiomatic Markdown under the ruled grammar and prose ownership) judged at the migration's Design Review with the emitted files as the exhibit. +- **M5 — parser-hardening baseline**: bounded parity matrix — the TS reifier's 10 authored-fidelity refusal classes (`src/extract/reify.ts:53-64`) each mapped to a same-class Markdown finding or an explicit named non-claim, checkout-forced cells added as ruled (skeleton lands in the repo plan before session work; execution against the settled post-migration grammar happens in the hardening session, after the grammar-hardening debt fixes so parity is measured once); the grammar-hardening debt cluster (YAML 1.2 scalar spellings · line-number polish · `...` closer · non-mapping-root accumulated findings · cap-flood · heading/GWT strictness · duplicate `When` + dead `mapOwner` branch · `reifyMarkdownCarrier` catch-all totality — review-06 lines 251–262); the phase-1 non-claim replaced by a parity claim with pinned evidence (the executed matrix) or a revised, explicitly scoped non-claim — claim text lands on the parser specs. +- **M6 — the canonical-default flip**: one commit (green at the blessed commit, no intermediate claim) — post-flip operative records (MD-18 Decision text discharges the transition clause; `CONTEXT.md` carrier resolved entry rewritten; the `sdp import` term was already amended at todo 7 (the multi-adapter verb — one import verb, many source adapters; the TS adapter landed; the gen-1 `.feature` adapter designed-in and deferred on the record) and is verified + machine-pinned here via the truth-script re-pin; `AGENTS.md` interim sentence replaced) · the post-flip operative sentence: *"Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option."* · consistency-script sweep (`check-carrier-interim.mjs` renamed `check-carrier-rule.mjs` with post-flip pins and a full reference sweep — its own header/usage lines, the comment in `check-carrier-truth.mjs:30`, a repo-wide grep for the old filename; `check-carrier-truth.mjs` CLAIMS re-pinned to post-flip wording, the interim sentence added to OBSOLETE, the TS-mention RULES classification audit retained; the Gate-4 phase-2 pins in `check-self-hosting-gates.mjs:187-197` retired/re-pointed — the plan-18 gate ledger itself is scaffolded at todo 1, not here) · the anti-misleading pass (concept `00`/`04`/`07`/README, jtbd stories, the example README already migrated, MD-15's wording re-checked, package docs/root README — inventory seeded from the truth script's audit map) · `04` §1's canonical-surface-config sentence repaired to aspirational wording. Closes in the **hard phase-internal owner gate** (acceptance 1–3 + 8 green before any corpus work). +- **M7 — corpus expansion** under the per-wave modeling policy (Execution strategy §policy, binding on every authoring todo): wave 1 = refine-target minimum — `spec:model.core-model`, `spec:model.spec-sections`, `spec:model.relations`, `spec:model.stable-ids`, `spec:model.pack-aggregate`, `spec:model.anchors`, `spec:validation.two-check-families`, `spec:consumers.projections-model`, `spec:consumers.agent-surface`, `spec:consumers.design-review`; validation wave — `spec:validation.referential-integrity`, `spec:validation.claim-separation`, `spec:validation.verification-linkage` (bundles verifies-linkage + oracle-linkage), `spec:validation.pack-coherence`, `spec:validation.authored-honesty` (bundles authoring-shape + delivery-facts), `spec:validation.warn-level-signals` (bundles orphans + gaps); consumers remainder — `spec:consumers.reader`, `spec:consumers.edit-model` (+ `spec:consumers.projections-model` enrichment); extraction/graph wave — `spec:extraction.excludes`, `spec:extraction.claim-taxonomy`, `spec:extraction.regenerability`, `spec:extraction.schema-versioning`, `spec:extraction.executable-contracts`, plus in-place enrichment of the phase-1 extraction seeds. Coverage ledger row per spec (concept path → spec id → readiness → anchor/verifier status → disposition note); every new ID enters `specs/self-hosting.pack.sdp.ts` in the same change; pack title/framing shed phase-1 wording; `pack:self-hosting-v1` ID unchanged. +- **M8 — the decision fold**: registry "Future spec id" column amended to `spec:decisions.*` at fold start; **19 new decision specs** — batch 1 carrier/extraction cluster (MD-14, MD-15, MD-17, MD-18, MD-19, MD-21), batch 2 model/phase cluster (MD-1, MD-2, MD-4, MD-5, MD-7, MD-10, MD-11, MD-12, MD-16), batch 3 (D3, D5, D6), **plus MD-20 landed at the S7 fold-completion** once its refine target `spec:extraction.excludes` exists (targets-exist ordering, never the fallback for a ruled-domain decision). Relation shape per owner §6.5: **each fold decision `refines` the most-specific subsystem spec it rules** (fallback `spec:protocol.self-hosting` only for genuinely phase-level decisions) **and** specs a decision shapes author `decidedBy` back-edges. Pinned refine-target map (→ wave that creates the target): MD-1 → `spec:protocol.self-hosting` (phase-level) · MD-2 → `spec:protocol.self-hosting` (phase-level) · MD-4 → `spec:model.core-model` (S5) · MD-5 → `spec:protocol.self-hosting` (phase-level) · MD-7 → `spec:model.anchors` (S5) · MD-10 → `spec:model.spec-sections` (S5) · MD-11 → `spec:model.spec-sections` (S5) · MD-12 → `spec:validation.readiness-floor` (exists) · MD-14 → `spec:validation.two-check-families` (S5) · MD-15 → `spec:carrier.markdown-authoring` (exists) · MD-16 → `spec:validation.readiness-floor` (exists — the carried-evidence law is implemented in `src/validate/readiness-floor.ts:150-181`; the multi-point remainder — the authoring-shape stand-in and the `doc:`-target deferral — rides as body content/consequences in the fold spec, and a `decidedBy` back-edge keeps the floor law linked, never orphaned) · MD-17 → `spec:model.spec-sections` (S5) · MD-18 → `spec:carrier.markdown-authoring` (exists) · MD-19 → `spec:carrier.prose-ownership-rule` (exists) · MD-20 → `spec:extraction.excludes` (S7) · MD-21 → `spec:carrier.envelope-contract` (exists) · D3 → `spec:model.pack-aggregate` (S5) · D5 → `spec:consumers.agent-surface` (S5) · D6 → `spec:consumers.projections-model` (S5). Each spec authored per the §4 rubric (three-part test · leanness 1–3 sentences per slot · current law only · plain language leads · zero procedural context). Per-row dispositions: D1 → carried by `spec:model.core-model` (verify named surface) · D2 → carried by `spec:model.spec-sections` (verify) · D4 → carried by `spec:consumers.design-review` (verify) · MD-8/MD-9/MD-13 folded/folds — verify the carrying surfaces (`src/model/anchors.ts` doc-comment, `src/model/sections.ts`, the `readiness-floor.ts` header), never re-author. The two born specs' keep-vs-repoint verdicts recorded in the fold ledger (recommendation: keep both at `spec:protocol.self-hosting` — genuinely phase-level; a relation edit only if the owner rules otherwise, never an ID change). Measured evidence: dispositions recorded at the fold (carrier spec + landing wave per figure); figure moves land **with their carrying waves** — `~⅕ tokens` → `spec:consumers.agent-surface` (S5) and `single-digit to ~25%` → `spec:consumers.projections-model` (S5) move at the fold; `< ~50 specs` and `~10k+ nodes` → `spec:extraction.regenerability` (S7) move at the S7 fold-completion; the section shrinks row-by-row and retires when empty. Diary bodies deleted once distilled (git is the archive); `DECISIONS.md` becomes the lean registry (name · one-line gloss · spec pointer · folded/CONTEXT-only dispositions) — the post-flip carrier sentence stays verbatim in the lean registry's MD-18 row (if its wording must change, `check-carrier-rule.mjs` is re-pinned in the same change); the D1–D6 table survives until the artifacts citing `(Dn)` dissolve. Fold ledger records every disposition (process record, never graph content). +- **M9 — the repo plan**: `plans/18-self-hosting-phase-2.md` (plan-17 shape: status header · context · scope · tranche sections with every engineering ruling · the checkout grammar matrix + the 10-row hardening-matrix skeleton as appendices · sessions S1–S8 · docket ledger seeded from plan 17 §6's deferred rows · fold ledger + measured-evidence ledger + coverage ledger **with actual templates, not just names** · the five named watch items with their disposition states · acceptance criteria 1–8 · gate ledger G1–G8 · PLAN-ONLY sections marked as such), kept in lockstep with execution; `check-self-hosting-gates.mjs` extended at todo 1 with the plan-18 gate-ledger block (G1–G8 definitions, pending-tolerant rows stamped `accepted` + SHA as sessions close — the same mechanism plan 17's ledger used); `AGENTS.md` "what now" pointer updated at land and at close **without touching the interim-pinned AGENTS.md sentence before the flip**. +- **M10 — debt intake**: review-06 clusters adopted per owner §6.8 — grammar-hardening → M5; exclude/CLI (Windows absolutes · `--exclude --foo` diagnostics · matcher regression coverage · library-seam wording) → the emitter/CLI session; Design Review cluster (dynamic-key ordering · escaping outside prose slots) ruled at migration/flip with a concrete decision prompt (recommendation: escape every rendered field uniformly + lexicographic dynamic-key ordering, with determinism tests — the prompt, options, and trade-offs spelled out in todo 15); records cluster (stale provenance wording at `05`:106 + `CONTEXT.md`:182 · plan-16 evidence dispositions · carrier-truth comment narrowing) → the fold session. No-reparse spy seam + temporal token assembly stay deferred (recorded in the docket). +- **M11 — phase close**: docket reconciliation (every carried row dispositioned done/deferred/dropped with why — nothing silently dropped) · whole-phase anti-drift audit (floor-honest readiness, no filler specs, all five watch items' states verified, acceptance 1–8 evidence assembled) · adversarial code review archived at `reviews/07-self-hosting-phase-2-code-review.md` with a verified remediation pass · installed-package proof covering `sdp import` + the new barrel exports · clean-clone `npm run check` green. +- **M12 — `spec:carrier.sdp-import` enriched** from `idea` upward only as the floor honestly clears (target `ready` with a real verifier: `spec:carrier.sdp-import.round-trip` example child → generated contract → bound test with test anchor, the duplicate-IDs tracer pattern; the new ID enters the pack manifest in the same change). + +### Must NOT have (guardrails, anti-slop, scope boundaries) + +- No dual-authored product spec ID on any blessed commit (one canonical surface per ID, no mixing — pack manifests lawfully stay TS; the dual-carrier test fixtures keep both surfaces by design). +- `sdp import` is a durable product verb — never a migration script; import never deletes `.sdp.ts` (deletion is the migration's own step); no second validation path (MD-14 — import consumes the reifier, it never re-validates). +- The TS DSL is not retired (import source + lawful per-ID option); no Markdown `Pack` syntax (watch item stands, unforced); no gen-1 `.feature` adapter implementation (designed-in, deferred on the record); no canonical-surface config mechanism (file-existence-only stands; `04` §1 repaired to aspirational). +- No table sugar unless the grammar matrix forces it under fire (expectation, never quota — unfired means the done-record says why the corpus never forced it). +- No per-doc concept deletion; no systematic tests-to-executable-specs rewrite (only opportunistic tracers); no editor-association work; no cosmetic concept-doc repair beyond the anti-misleading pass. +- Checks police conformance and honesty, never content-quality and never workflow — the §4 rubric is review discipline, never a validator; "deterministically validated," never "provably correct." +- Delivery facts derived, never authored; the claim taxonomy never collapsed; no readiness above floor; no filler/thin specs; a kind authored only where real content forces it. +- No status vocabulary on decision specs (`decision.status` rejected; ADR status frontmatter never leaks in); amendment history lives in git, not spec bodies. +- Determinism stays regenerate-and-compare (`--check-clean`) — never committed goldens. +- No `git stash`; commit early on the effort branch; commit/push only when the owner asks. + +## Verification strategy +> Zero human intervention - all verification is agent-executed. Owner gates (G1–G8) are the single exception: Atlas prepares the evidence pack, the owner dispositions, Atlas records. + +- Test decision: **TDD** (vitest, RED evidence quoted before every behavioral step) for the emitter, CLI verb, round-trip harness, parity matrix, and every debt-cluster fix; **tests-after** for corpus-wave and fold authoring (no behavior to drive — conformance + honesty are enforced by `npm run check`); agent-executed QA everywhere. +- The green gate: `npm run check` — `package.json:49` (check:temporal → lint → format:check → build → generate:self-hosting → generate:example → typecheck → typecheck:examples → test → check:self-hosting → check:example → preflight). Green at every blessed commit; full clean-clone run for gate-affecting changes. +- Evidence: `.omo/evidence/self-hosting-phase-2/task--self-hosting-phase-2.` (outside ulw-loop). Every QA scenario writes its artifact there. + +## Execution strategy + +Sessions are owner-gated (each closes with a Design Review and a gate-ledger row stamped with owner disposition + accepted SHA). Waves are Atlas execution batches inside sessions; the DAG is sequential across sessions by owner ruling (brief §6.3/§6.5), with intra-session parallelization only where the matrix marks it. + +### Per-wave modeling policy (binding on every tranche-3 authoring todo) + +- **Altitude**: subsystem parents at `feature` refining the `spec:protocol.self-hosting` epic; leaf specs at `story`/`feature` as content demands; never a refinement-direction inversion. +- **Kind**: a validator check earns a `rule` spec when its law is independently statable; bundling is lawful where one law spans several mechanical checks (the pre-declared bundles in M7 — never a count KPI); relation laws live as model facts; durable rulings live as decision specs (the fold), never doubled into ordinary specs. +- **Anchor density**: anchors only on named realizing entrypoints (the validator functions in `src/validate/validators.ts`, `evaluateReadinessFloor`, `deriveGraph`, `computeDeliveryFacts`, `createReader`, `renderDesignReview`, `generateContracts`, the two reifiers) — 1–3 precise anchors per anchored spec; no decorative anchors; `implemented` stays derived and honest. +- **Executable bar**: a spec earns `has-verifier` only where the verify loop is genuinely cheap (fixture-based contract binding per the duplicate-IDs tracer template); default `manual`/`reviewed`. +- **Coverage ledger columns**: concept path (doc §) → spec id → readiness → anchor/verifier status → disposition note. The waves produce a typed, impactable delivery model of the engine — never `docs/concept` in frontmatter form. + +### Coverage ledger + +| Concept path | Spec ID | Readiness | Anchor/verifier status | Disposition note | +|---|---|---|---|---| +| `02 §1-2 primitive and descriptors` | `spec:model.core-model` | defined | `src/model/spec.ts` and `src/model/descriptors.ts` code anchors; no verifier | wave 1a refine target and shared vocabulary carrier | +| `02 §3 sections` | `spec:model.spec-sections` | defined | `src/model/sections.ts` and `src/validate/readiness-floor.ts` code anchors; no verifier | wave 1a section, typing-law, and verifier-semantics model | +| `02 §6 relations` | `spec:model.relations` | defined | `src/model/relations.ts` code anchor; no verifier | wave 1a relation vocabulary | +| `02 §5 stable identity` | `spec:model.stable-ids` | defined | `src/ids.ts` code anchor at `parseId`; no verifier | wave 1a stable-ID rule | +| `02 §4 Pack` | `spec:model.pack-aggregate` | defined | `src/model/pack.ts` code anchor; no verifier | wave 1a Pack aggregate model | +| `04 §2 generic source anchors` | `spec:model.anchors` | defined | `src/model/anchors.ts` and `src/extract/anchors.ts` code anchors; no verifier | wave 1a binding and extraction model | +| `05 §1 two check families and layered enforcement` | `spec:validation.two-check-families` | defined | `src/validate/validators.ts` code anchor at `graphValidatorIds`; no verifier | wave 1b validation parent and MD-14 refine target | +| `05 §2 MVP graph validators` | `spec:validation.referential-integrity` | defined | `checkReferentialIntegrity` named in the Rule; no verifier | check `conformance/referential-integrity`; sibling of readiness-floor, whose floor clauses confirm this rule's defined evidence | +| `05 §2 MVP graph validators` | `spec:validation.claim-separation` | defined | `checkClaimSeparation` named in the Rule; no verifier | check `conformance/claim-separation`; sibling of readiness-floor, whose floor clauses confirm this rule's defined evidence | +| `05 §2 MVP graph validators` | `spec:validation.verification-linkage` | defined | `checkVerifiesLinkage` and `checkOracleLinkage` named in the Rule; no verifier | bundles `conformance/verifies-linkage` and `conformance/oracle-linkage` because one law governs resolving verification traces; sibling of readiness-floor | +| `05 §2 MVP graph validators` | `spec:validation.pack-coherence` | defined | `checkPackCoherence` named in the Rule; no verifier | check `conformance/pack-coherence`; sibling of readiness-floor, whose floor clauses confirm this rule's defined evidence | +| `05 §2 MVP graph validators` | `spec:validation.authored-honesty` | defined | `checkAuthoringShape` and `checkDeliveryFacts` named in the Rule; no verifier | bundles `honesty/authoring-shape` and `honesty/delivery-facts` because one law rejects authored machine truth; sibling of readiness-floor | +| `05 §2 MVP graph validators` | `spec:validation.warn-level-signals` | defined | `checkOrphans` and `checkGaps` named in the Rule; no verifier | bundles `conformance/orphans` and `honesty/gaps` because one law keeps informative signals non-failing; sibling of readiness-floor | +| `06 §1-2 projections taxonomy and two surfaces` | `spec:consumers.projections-model` | defined | `src/projections/design-review.ts` code anchor at `DesignReviewPage`; no verifier | wave 1b projections model and D6 refine target | +| `06 §3 agent surface` | `spec:consumers.agent-surface` | defined | `src/reader/reader.ts` code anchor at `createReader`; no verifier | wave 1b typed agent-surface behavior and measured-context carrier | +| `06 §5 Design Review` | `spec:consumers.design-review` | defined | `src/projections/design-review.ts` code anchor at `renderDesignReview`; no verifier | wave 1b flagship human projection and D4 carrying surface | +| `06 §3 reader` | `spec:consumers.reader` | defined | `src/reader/reader.ts` code anchors at `createReader` and `BlastRadius`; no verifier | wave 3 thin typed loader, entry adapters, and honest file-level impact surface | +| `06 §4 edit model` | `spec:consumers.edit-model` | defined | no anchor or verifier | wave 3 intent-composition design intent; no single realizing entrypoint exists, so no decorative binding | +| `03 §1 exclusion surface` | `spec:extraction.excludes` | defined | `src/extract/index.ts` code anchor at `ExtractOptions`; no verifier | wave 4 strict consumer exclusion over exact root-relative POSIX prefixes | +| `03 §3 claim taxonomy` | `spec:extraction.claim-taxonomy` | defined | `src/graph/schema.ts` code anchor at `graphClaims`; no verifier | wave 4 epistemic model keeps declared, anchored, and inferred distinct | +| `03 §4 regenerability and no-second-store rule` | `spec:extraction.regenerability` | defined | `src/cli/sdp.ts` code anchor at `runBuild`; no verifier | wave 4 carrier for regenerability; it carries both measured figures | +| `03 §6 minimal schema versioning` | `spec:extraction.schema-versioning` | defined | `src/graph/schema.ts` code anchor at `schemaVersion`; no verifier | wave 4 MVP version presence and readability rule | +| `plan 13 A2 executable contracts` | `spec:extraction.executable-contracts` | defined | `src/codegen/contracts.ts` code anchor at `generateContracts`; no verifier | wave 4 graph-derived step and space contract behavior | +| `03 §5 git as the event log` | `spec:extraction.derive-graph` | ready | existing `src/extract/index.ts` and `src/extract/derive.ts` anchors; verifier unchanged | exposition-only content carried in the existing ready derive-graph Spec; no additional standalone Spec authored | + +### Fold ledger + +| Source | Terminal disposition | Evidence surface | +|---|---|---| +| MD-1 | decision Spec | `spec:decisions.executable-meta-model` | +| MD-2 | decision Spec | `spec:decisions.adopt-the-nouns` | +| MD-4 | decision Spec | `spec:decisions.one-primitive` | +| MD-5 | decision Spec | `spec:decisions.protocol-naming` | +| MD-7 | decision Spec | `spec:decisions.binding-not-liveness` | +| MD-8 | folded | `src/model/anchors.ts` `codeAnchor` documentation | +| MD-9 | folded | `src/model/sections.ts` and the readiness floor | +| MD-10 | decision Spec | `spec:decisions.content-only-sections` | +| MD-11 | decision Spec | `spec:decisions.typing-law` | +| MD-12 | decision Spec | `spec:decisions.kind-conditional-floor` | +| MD-13 | folded | `docs/concept/05-validation-and-honesty.md` and `src/validate/readiness-floor.ts` | +| MD-14 | decision Spec | `spec:decisions.one-validation-path` | +| MD-15 | decision Spec | `spec:decisions.sdp-ts-extension` | +| MD-16 | decision Spec | `spec:decisions.carried-evidence` | +| MD-17 | decision Spec | `spec:decisions.point-per-example` | +| MD-18 | decision Spec | `spec:decisions.carrier-ruling` | +| MD-19 | decision Spec | `spec:decisions.prose-ownership` | +| MD-20 | decision Spec | `spec:decisions.exclusion-contract`; `spec:extraction.excludes` names its `decidedBy` back-edge | +| MD-21 | decision Spec | `spec:decisions.envelope-grammar-posture` | +| D1 | carried | `spec:model.core-model` | +| D2 | carried | `spec:model.spec-sections` | +| D3 | decision Spec | `spec:decisions.pack-reified` | +| D4 | carried | `spec:consumers.design-review` | +| D5 | decision Spec | `spec:decisions.agent-surface-scripts-graph` | +| D6 | decision Spec | `spec:decisions.mcp-deferred` | +| ~1/5 tokens | measured evidence moved | `spec:consumers.agent-surface` | +| single-digit to ~25% | measured evidence moved | `spec:consumers.projections-model` | +| < ~50 specs | measured evidence moved | `spec:extraction.regenerability` | +| ~10k+ nodes | measured evidence moved | `spec:extraction.regenerability` | +| Measured-evidence registry section | retired | all four figures now have owning Specs | +| `spec:decisions.plain-language-references` | kept | phase-level relation remains on `spec:protocol.self-hosting` | +| `spec:decisions.concept-docs-dissolve` | kept | phase-level relation remains on `spec:protocol.self-hosting` | + +### Docket Ledger, Reconciled at Todo 40 + +The full close ledger is [task-40 docket evidence](../evidence/self-hosting-phase-2/task-40-self-hosting-phase-2.docket.md). +Every plan-17 carry-forward, review-06 intake, phase-2 addition, and watch item has one terminal +disposition. Two rows remain intentionally deferred for phase 2: no-reparse spy coverage because +named-import interception remains weaker than an injected read seam, and temporal token assembly +because its carried-forward rationale remains sanctioned. No row was silently dropped. + +| Docket item | Origin | Terminal disposition | Evidence | +| --- | --- | --- | --- | +| YAML scalar, line-number, document-end, non-mapping-root, cap-flood, GWT, heading, and duplicate-`When` grammar rows | plan-17 and review-06 grammar cluster | done s3 | [Task 18](../evidence/self-hosting-phase-2/task-18-self-hosting-phase-2.vitest.log) | +| Windows-exclude, malformed-flag, matcher, and library-wording rows | plan-17 and review-06 exclude cluster | done s1 | [Task 10](../evidence/self-hosting-phase-2/task-10-self-hosting-phase-2.red.log), [task-40 green gate](../evidence/self-hosting-phase-2/task-40-self-hosting-phase-2.acceptance.md#current-green-gate) | +| Dynamic-key ordering and non-prose escaping | plan-17 and review-06 Design Review cluster | done s2 | [Task 15](../evidence/self-hosting-phase-2/task-15-self-hosting-phase-2.vitest.log) | +| Indirect `then`, GWT permutation, `description`, bound-example, and fixture-byte rows | plan-17 phase-1 remediations | dropped already fixed by phase-1 remediation | [plan-17 record](../../plans/17-self-hosting-v1.md) | +| Row-3 enrichment delta | plan-17 | dropped it remains the sanctioned `scoped` to `defined` maturity record | [plan-17 record](../../plans/17-self-hosting-v1.md) | +| No-reparse spy coverage | plan-17 | deferred Named-import interception remains weaker than an injected read seam. | [plan-17 record](../../plans/17-self-hosting-v1.md) | +| Carrier-truth comment, stale provenance, and plan-16 evidence dispositions | plan-17 and review-06 records cluster | done s6 | [fold ledger](#fold-ledger), [Task 39](../evidence/self-hosting-phase-2/task-39-self-hosting-phase-2.g7.md) | +| Temporal token assembly | plan-17 | deferred The comment can be narrowed later; token assembly remains the sanctioned temporal-guard pattern. | [plan-17 record](../../plans/17-self-hosting-v1.md) | +| Twelfth preflight leg | plan-17 | dropped the recorded chain delta is benign and adds no phase-2 work | [plan-17 record](../../plans/17-self-hosting-v1.md) | +| Decision-spec namespace divergence | plan-17 | done s6 | [Task 39](../evidence/self-hosting-phase-2/task-39-self-hosting-phase-2.g7.md) | +| Import public package proof, checkout fidelity, parser claim, canonical flip, decision fold, and corpus review | phase-2 additions | done s1-s7 | [task-40 acceptance assembly](../evidence/self-hosting-phase-2/task-40-self-hosting-phase-2.acceptance.md) | +| Five watch items | phase-2 additions | done s7, all unfired with named reasons | [task-40 watch dispositions](../evidence/self-hosting-phase-2/task-40-self-hosting-phase-2.docket.md#watch-items) | + +### Anti-Drift Audit, Todo 40 + +**Verdict: PASS.** The [G7 evidence pack](../evidence/self-hosting-phase-2/task-39-self-hosting-phase-2.g7.md) +remains the authoritative floor-honest table: 58 corpus Specs, 7 `ready`, 51 `defined`, no +`idea` or `scoped` state, no orphan, and no unresolved double claim. No filler Spec was accepted: +the explicit validator filler rejection is recorded in +[task 35](../evidence/self-hosting-phase-2/task-35-self-hosting-phase-2.filler.log). All five +watch items are terminal and unfired with a recorded reason. The acceptance-1-to-8 mapping lives +in [task-40 acceptance evidence](../evidence/self-hosting-phase-2/task-40-self-hosting-phase-2.acceptance.md). + +### Watch-item re-check + +| Watch item | Current state | Re-check result | +|---|---|---| +| table sugar | terminal, unfired | Waves 2-4 used existing sibling Specs and ruled body forms, so no table grammar was forced. | +| single-literal vocabulary form | terminal, unfired | No new vocabulary carrier needed a form beyond the ruled literal syntax. | +| multi-entry constraint form | terminal, unfired | No wave introduced a constraint that required multiple entries. | +| array-section prose sub-owner | terminal, unfired | New prose remains under existing typed section owners. | +| Markdown Pack syntax | terminal, unfired | The Pack remains a TypeScript manifest, and no caller required Markdown authoring. | + +### Planning-review record (durable — every planning finding folded, with its resolution; audit without chat history) + +**Metis gap analysis (pre-todo, 17 findings — all folded with each finding recorded):** (1) MD-20 added as the 19th fold spec, landing at the S7 fold-completion against `spec:extraction.excludes` (targets-exist); (2) the fold relation shape restored to both edges — `refines` forward + `decidedBy` back-edges; (3) grammar-matrix confirmation scheduled as todo 2 before emitter design, M1 reworded to the post-gap grammar; (4) plan-18 gate-ledger scaffolding moved to todo 1, gate set unified G1–G8; (5) CLI contract gaps pinned in M2 (per-file fail-collecting, finding split, documentation homes); (6) per-wave modeling policy section added above; (7) watch-item ownership assigned (todos 16, 38, 40); (8) dependency matrix rebuilt contradiction-free; (9) migration + flip one-commit atomicity laws in Commit strategy; (10) measured-evidence split — dispositions at the fold, moves with carrying waves; (11) barrel contract unified: `emitMarkdownSpec` + `importTypeScriptSpec` (both pure, fs-free); (12) D1/D2/D4 carrying specs named in M8/todo 32; (13) Wave-0 single-todo exception annotated; (14) delta catalog lands in the G2 done-record (todo 17), authored-level layer's zero-prose-delta discipline stated in M3; (15) born-spec keep-vs-repoint verdicts at todo 32; (16) the interim-script rename's reference sweep enumerated in todo 22, and todo 1 guards the interim-pinned strings; (17) todo 1 acceptance requires the actual ledger templates, the 10-row hardening skeleton, and PLAN-ONLY markers. + +**External consolidated review (post-todo, 9 valid findings folded — one claim rejected):** (1) the glossary-contradiction window closed per Option A — the `sdp import` glossary amendment moved from the flip to todo 7 (verified: no script pins the row's current text), the flip now verifies + machine-pins it; (2) todo 22's acceptance grep rewritten with explicit `--exclude-dir`s and a named active-surface list, plus the standing policy that past-tense historical mentions in plans/reviews/.omo are lawful; (3) MD-16 re-pointed to `spec:validation.readiness-floor` (verified at `src/validate/readiness-floor.ts:150-181` — MD-12 refines the same target); (4) todo 5's pack detection re-based on `CarrierReification.packs` non-empty, with `import/empty` for empty modules; (5) todo 9 gained the drift-alarm QA probe (the rejected claim: renaming `test:protocol.sdp-import.round-trip` — phase-1 convention is `test:protocol.*` for carrier specs too, e.g. `test:protocol.markdown-parser`; the name stands); (6) F1–F4 expanded to phase-1 density with pinned evidence paths, F3 executed as agent-run live QA; (7) this record created so the fold is auditable; (8) acceptance-8 ownership re-aligned (todo 7 now genuinely lands the amendment); (9) todo 5's suite pins pass-through finding identity (`validatorId`/`file`/`line`) for non-error cases. + +### Whole-phase done-record + +- **Executed delta catalog:** the importer preserves authored `ReifiedSpec.data` with zero prose normalization. The graph comparison normalizes only the carrier file suffix from `.sdp.ts` to `.sdp.md`; physical envelopes and TypeScript source comments remain outside graph content; delivery facts are recomputed under the same anchor set; and `schemaVersion` remains unchanged. The semantic equality gate and the emitted-Markdown authoring-surface review both passed. +- **Executed delivery:** `sdp import` landed as the durable, fs-free import seam with the CLI write-beside and `--dry-run` boundary; all eleven checkout Specs migrated atomically to Markdown; the canonical-default carrier rule replaced the interim rule; the bounded parser refusal-parity claim, decision-spec fold, and four honest corpus waves landed. The resulting self-hosting graph holds 58 Specs, 1 Pack, and 36 anchors, with 7 `ready` and 51 `defined` Specs. +- **Session SHA summary:** G1 is `f06f14d`; G2 is `df444f2`; G3 is `0bb200a`; G4, G5, G6, and G8 are consolidated at `67cfda7` under the continuation directive; G7 remains `b471189034e1ee238394f3364c349937be6bebed`. +- **Watch items:** all five terminal states are unfired. Table sugar was not forced because waves used existing sibling Specs and ruled body forms. The single-literal vocabulary form was unnecessary beyond the ruled literal syntax. No wave required a multi-entry constraint. New prose stayed under existing typed section owners, leaving the array-section sub-owner unfired. The Pack remains a TypeScript manifest, with no caller requiring Markdown Pack authoring. +- **Deferred tail:** no-reparse spy coverage remains deferred because named-import interception is weaker than an injected read seam. Temporal token assembly remains deferred because the sanctioned temporal-guard pattern still applies; its comment can be narrowed later. +- **Lean registry rulings:** the phase admitted `spec:decisions.*` records for the executable meta-model, naming and one-primitive laws, binding and section ownership, the typing and readiness-floor laws, one validation path, the `.sdp.ts` extension and carrier ruling, prose ownership, point-per-example, and the exclusion and envelope-grammar contracts. The Pack, agent-surface, and deferred-MCP decisions joined them; D1, D2, and D4 remain carried by their ordinary Specs. Each admission passed the three-part durability test and is linked from the lean registry. +- **Close evidence:** the acceptance assembly, adversarial-review remediation, and clean-clone and installed-package proofs are recorded in [task 40](../evidence/self-hosting-phase-2/task-40-self-hosting-phase-2.acceptance.md), [task 41](../evidence/self-hosting-phase-2/task-41-self-hosting-phase-2.review.md), and [task 42](../evidence/self-hosting-phase-2/task-42-self-hosting-phase-2.final-proofs.log). + +### Parallel execution waves +> Target 5-8 todos per wave. Fewer than 3 (except the final) means you under-split. Wave 0 is a deliberate single-todo foundational exception (repo plan + branch — everything else depends on it). + +- Wave 0 (foundational): todo 1. +- Wave 1 (S1a — grammar matrix + emitter core): todos 2–6. +- Wave 2 (S1b — verb standing + proof): todos 7–12 · closes session 1 (G1). +- Wave 3 (S2 — migration under fire): todos 13–17 · closes session 2 (G2). +- Wave 4 (S3 — hardening baseline): todos 18–21 · closes session 3 (G3). +- Wave 5 (S4 — the flip, HARD gate): todos 22–24 · closes session 4 (G4; acceptance 1–3 + 8 verified before wave 6). +- Wave 6 (S5 — corpus wave 1, refine-target minimum): todos 25–28 · closes session 5 (G5). +- Wave 7 (S6 — the decision fold): todos 29–34 · closes session 6 (G6). +- Wave 8 (S7 — corpus waves 2–4 + fold completion): todos 35–39 · closes session 7 (G7). +- Wave 9 (S8 — whole-phase close): todos 40–43 · closes the phase (G8). + +### Dependency matrix +| Todo | Depends on | Blocks | Can parallelize with | +| --- | --- | --- | --- | +| 1 | — | 2–43 | — | +| 2 | 1 | 3 | — | +| 3 | 2 | 4, 5 | — | +| 4 | 3 | 5, 8 | — | +| 5 | 3, 4 | 6, 7, 8 | — | +| 6 | 5 | 11 | 7, 8 | +| 7 | 5 | 10, 11 | 6, 8 | +| 8 | 4, 5 | 9, 12 | 6, 7 | +| 9 | 8 | 12 | 10, 11 | +| 10 | 7 | 12 | 6, 8, 9, 11 | +| 11 | 6, 7 | 12 | 8, 9, 10 | +| 12 | 9, 10, 11 | 13 | — | +| 13 | 12 | 14, 15, 16 | — | +| 14 | 13 | 17 | 15, 16 | +| 15 | 13 | 17 | 14, 16 | +| 16 | 13 | 17 | 14, 15 | +| 17 | 14, 15, 16 | 18 | — | +| 18 | 17 | 19 | — | +| 19 | 18 | 20 | — | +| 20 | 19 | 21 | — | +| 21 | 20 | 22 | — | +| 22 | 21 | 23 | — | +| 23 | 22 | 24 | — | +| 24 | 23 | 25, 26 | — | +| 25 | 24 | 27 | 26 | +| 26 | 24 | 27 | 25 | +| 27 | 25, 26 | 28 | — | +| 28 | 27 | 29 | — | +| 29 | 28 | 30, 31 | — | +| 30 | 29 | 32 | 31 | +| 31 | 29 | 32 | 30 | +| 32 | 30, 31 | 33 | — | +| 33 | 32 | 34 | — | +| 34 | 33 | 35, 36, 37 | — | +| 35 | 34 | 38 | 36, 37 | +| 36 | 34 | 38 | 35, 37 | +| 37 | 34 | 38 | 35, 36 | +| 38 | 35, 36, 37 | 39 | — | +| 39 | 38 | 40 | — | +| 40 | 39 | 41, 42 | — | +| 41 | 40 | 43 | 42 | +| 42 | 40 | 43 | 41 | +| 43 | 41, 42 | F1–F4 | — | + +## §10 Gate ledger G1-G8 + +This ledger is git process evidence, never graph content. It records owner gates and evidence; +its rows are not validators and do not alter readiness. `check-self-hosting-gates.mjs` verifies +the ledger's complete, non-empty G1-G8 structure while the repo plan exists. + +| Gate | Meaning | Entry condition | Evidence / owner disposition | +|---|---|---|---| +| G1 | emitter contract and grammar freeze | §2 matrix reviewed before emitter design | accepted — 2026-07-18 — `f06f14d` | +| G2 | importer fidelity | imported fixture graph equals source under §7 catalog | accepted — 2026-07-18 — df444f2 | +| G3 | hardening baseline | §3 classes have evidence or named non-claim | accepted — 2026-07-18 — 0bb200a | +| G4 | canonical-default flip | operative records and post-flip truth pins agree | accepted — 2026-07-19 — 67cfda7; accepted by consolidation at the whole-phase close under the continuation directive | +| G5 | first corpus-wave targets | core model, validation, and consumers parents exist | accepted — 2026-07-19 — 67cfda7; accepted by consolidation at the whole-phase close under the continuation directive | +| G6 | decision fold | every fold source has one ledger disposition | accepted — 2026-07-19 — 67cfda7; accepted by consolidation at the whole-phase close under the continuation directive | +| G7 | corpus waves 2–4 + fold completion | coverage ledger complete and fold ledger terminal | accepted — 2026-07-19 — `b471189034e1ee238394f3364c349937be6bebed` | +| G8 | whole-phase close | full gate, clean-clone proof, review remediation, owner acceptance | accepted — 2026-07-19 — 67cfda7; accepted by consolidation at the whole-phase close under the continuation directive | + +### G7 attached evidence + +The manifest-wide coverage-ledger completeness audit and floor-honest readiness table are attached +in [the G7 evidence pack](../evidence/self-hosting-phase-2/task-39-self-hosting-phase-2.g7.md). +It records all 58 Pack members, their concept-document contracts, their frontmatter relations, +and the applicable readiness evidence. The 2026-07-19 owner disposition is accepted because the +user explicitly directed continuation without incremental owner reviews. + +## Todos +> Implementation + Test = ONE todo. Never separate. + +- [x] 1. Land the repo-facing phase plan, the effort branch, and the plan-18 gate-ledger scaffolding + What to do / Must NOT do: Create (or switch to) branch `feature/protocol-self-application-phase-2` — never commit to `main`. Author `plans/18-self-hosting-phase-2.md` in plan-17 shape with these sections in order: (a) status header `🧭 DRAFTED` + spec anchors blockquote (the brief `plans/18-self-hosting-phase-2-brief.md`, plan 17, the carrier ruling MD-18); (b) Context — why this plan exists; (c) Scope (three tranches + §8 out-of-scope mirrored); (d) §1 tranche-1 engineering rulings (the CLI contract from M2, the emitter architecture from M1, the two-layer fidelity contract + pre-declared 5-item delta catalog from M3, the migration atomicity law, the canonical-surface disposition = file-existence-only with `04` §1 repaired to aspirational); (e) §2 the checkout grammar matrix appendix — kind × section × phase-1 support for all 11 checkout specs (the inventory is verified in this work plan's draft Findings; seed cells: the parent `exampleSpace` on `spec:orders.create-order`, multi-relation envelopes up to three keys, the frozen one-entry constraint form — every cell currently maps onto the 17b grammar); (f) §3 the 10-row hardening-matrix skeleton appendix — each TS refusal class from `src/extract/reify.ts:53-64` (parse-error · non-static-envelope · invalid-id · duplicate-id · reserved-property · non-static-section · unowned-prose · unrecognized-statement · unrecognized-property · misplaced-authoring) mapped to `same-class MD finding | named non-claim | checkout-forced cell (empty until ruled)`; (g) §4 tranche-2 fold design (namespace amendment, the pinned 19-row refine-target map from M8, the §4 rubric, fold-ledger template: `registry row → disposition → target spec id → refines target → decidedBy back-edges → evidence surface → three-part-test verdict`); (h) §5 tranche-3 wave design (the per-wave modeling policy verbatim from this plan, the M7 spec lists, coverage-ledger template: `concept path → spec id → readiness → anchor/verifier status → disposition note`, measured-evidence ledger template: `figure → generalized-claim spec → landing wave → move state`); (i) §6 rulings-under-fire running log (empty, seeded with the procedure); (j) §7 sessions S1–S8 with per-session Design Review agendas; (k) §8 docket ledger (plan-17 format: `Docket item (origin) | Planned disposition | Execution state`) seeded with every plan-17 §6 deferred row (all rows at `plans/17-self-hosting-v1.md:445-468`), the review-06 clusters per owner §6.8, the five watch items (table sugar · single-literal vocabulary form · multi-entry constraint form · array-section prose sub-owner · Markdown Pack syntax) each with state `watch`, and the two stay-deferred items; (l) §9 acceptance criteria 1–8 verbatim from the brief with the owning todo numbers from this plan; (m) §10 gate ledger G1–G8 (7 columns like plan 17: `Gate | Meaning | Owner disposition | Date | Accepted SHA | Corrections | Rulings`), all rows `pending`; (n) mark PLAN-ONLY sections (§1–§6 design material) vs execution sections (§7–§10) explicitly. Extend `check-self-hosting-gates.mjs` with a plan-18 block: the G1–G8 gate definitions, pending-tolerant (a row is structurally required; `accepted` + date + SHA required only once stamped — mirror however the script tolerated plan-17's ledger mid-build at `check-self-hosting-gates.mjs:52-61,159-175`), leaving every plan-17 enforcement untouched. Update `AGENTS.md`'s "what now" status clause to point at plan 18 as DRAFTED (plan 17 stays the latest ✅ EXECUTED ground reference) — do NOT touch the interim-carrier-rule sentence pinned by `check-carrier-interim.mjs:43-44` or any other pinned string. Update the docket note in `plans/18-self-hosting-phase-2-brief.md`? NO — the brief is a frozen input record; never edit it. Must NOT: edit any product code, any spec, any concept doc, or the brief; must NOT add gate SHAs (none exist); must NOT wire the carrier/gates audit scripts into `npm run check` (the deliberate non-wiring decision stands, `plans/17-self-hosting-v1.md:385`). + Parallelization: Wave 0 | Blocked by: — | Blocks: 2–43 + References (executor has NO interview context - be exhaustive): plan-17 shape `plans/17-self-hosting-v1.md:1-15` (status header), `:362-372` (docket ledger), `:397-410` (gate ledger), `:445-468` (deferred rows); gates script `check-self-hosting-gates.mjs:10-29` (six enforcement groups), `:52-61` (gate definitions), `:130-154`, `:187-197`; interim pins to avoid `check-carrier-interim.mjs:43-53`; AGENTS.md status block (top of file); the brief `plans/18-self-hosting-phase-2-brief.md` §3/§7/§9; this plan's M1–M12 for section (d)–(h) content; the 17b frozen grammar `plans/17b-self-hosting-sessions-1-4.md:122-156`. + Acceptance criteria (agent-executable): `git branch --show-current` prints `feature/protocol-self-application-phase-2`; `test -f plans/18-self-hosting-phase-2.md` and the file contains all fourteen sections (a)–(n) including the two matrix appendices and three ledger templates; `node ./check-self-hosting-gates.mjs` exits 0; `npm run check` exits 0. + QA scenarios (name the exact tool + invocation): happy — `npm run check` green with the new plan present (Evidence `.omo/evidence/self-hosting-phase-2/task-1-self-hosting-phase-2.check.log`); failure — temporarily remove a G1–G8 definition from the script block, re-run `node ./check-self-hosting-gates.mjs`, confirm exit 1 with a naming failure, revert (Evidence `.../task-1-self-hosting-phase-2.gates-failure.log`). + Commit: Y | `docs(plans): land the phase-2 self-hosting plan (drafted) with gate-ledger scaffolding` +- [x] 2. Confirm the checkout grammar matrix against the real tree (PLAN-ONLY verification, evidence-producing) + What to do / Must NOT do: Walk all 11 checkout spec files cell-by-cell against the frozen 17b heading→section map and envelope grammar; for each spec record: kind, altitude, readiness, every section used, every relation key used, and the 17b row that covers it (or `GAP — no ruled form`). The expected result (verified at planning): all 11 covered — behavior/epic/feature with `intent`+`behavior.rules` → `## Intent`/`## Behavior`; workflow flows → `## Workflow`; exampleSpace vocabulary → `## Example space` + one `gwt-vocabulary` fence; example bound points (full and partial `{n: 0}`) → one `gwt` fence immediately after `## Intent`; contract/idea with blocking openQuestions → `### Open questions` `- [blocking]`; rule → `## Rule`; constraint single entry (flavor/statement/target, no measurableBy) → `## Constraints` one-entry form; model terms → `## Model`; decision (decision/rationale/consequences) → `## Decision`; verification executable+criteria → `## Verification — executable`; relation keys seen (refines, dependsOn, constrainedBy, decidedBy, verifies) all in the envelope's six-key set; multi-target `dependsOn` sequences → sequence form; every spec has `title:` → H1; none has `narrative:`. Also cross-check the 10-row hardening-matrix skeleton: for each TS refusal class, note the MD-side finding ID candidate or `non-claim candidate` (from the MD finding inventory at `src/extract/markdown-support.ts:3-13`, `markdown-body.ts:35-37,72-93`, `markdown.ts:100-111,158-196`, `markdown-body-content.ts:233-241`, `markdown-body-owner-support.ts:26-35`). Record the confirmed matrices in the repo plan appendices (edit plan 18 §2/§3 cells from `unconfirmed` to `confirmed` with this evidence). Any GAP cell becomes an under-fire ruling seed in §6 — never silently design around it. Must NOT: design emitter behavior for GAP cells before a ruling; change any source file. + Parallelization: Wave 1 | Blocked by: 1 | Blocks: 3 + References: the 11 files under `examples/checkout-v1/specs/**` (10 orders + 1 decisions); 17b tables `plans/17b-self-hosting-sessions-1-4.md:122-156`; MD finding IDs at the paths above; TS refusal classes `src/extract/reify.ts:53-64`; verified per-spec inventory in `.omo/drafts/self-hosting-phase-2.md` Findings (lane-5 report). + Acceptance criteria: the confirmation table exists in the evidence file with 11/11 specs covered or every GAP recorded as a §6 ruling seed; plan 18 §2/§3 appendices updated; `npm run check` green. + QA scenarios: happy — full walk recorded (Evidence `.omo/evidence/self-hosting-phase-2/task-2-self-hosting-phase-2.grammar-matrix.md`); failure — pick one covered cell, temporarily assert it uncovered, confirm the §6 ruling-seed procedure triggers (a GAP line is produced), revert (Evidence `.../task-2-self-hosting-phase-2.gap-procedure.log`). + Commit: Y | `docs(plans): confirm the checkout grammar matrix and hardening skeleton against the tree` +- [x] 3. Emitter core — `src/import/emit-markdown.ts`: envelope, identity, narrative, relations (TDD) + What to do / Must NOT do: Create the `src/import/` module (a write path — the boundary against read-only `src/extract/` is ruled). Implement `emitMarkdownSpec(reified: ReifiedSpec): string`, a pure function producing the `.sdp.md` document: frontmatter with exactly `id`, `kind`, `altitude`, `readiness`, `relations` (key order as listed; `relations: {}` when empty — the honest carrier syntax, `check-carrier-interim.mjs:48-49`'s relations distinction); relation records (`{type,target,claim:"declared"}` from `reifyTypeScriptCarrier`) grouped by type preserving first-seen key order and per-key list order, scalar for a single target, sequence for multiple, all six keys supported (refines, dependsOn, constrainedBy, decidedBy, verifies, supersedes); H1 from `data.title` (the parser maps H1 → `data.title`, `src/extract/markdown-body.ts:133,189`, so round-trip holds); `data.narrative` as CommonMark prose between H1 and the first H2. Deterministic byte output for identical input. TDD: write the failing tests FIRST (`test/import-emit-markdown.test.ts`) — envelope-only spec, each relation key, multi-target sequence, empty relations, title→H1, narrative placement — quote the RED output in evidence, then implement to green. Must NOT: read or write files (pure — no `node:fs` import); consume graph nodes (the graph is derived; the emitter consumes the authored IR only); emit any key beyond the envelope grammar (title/narrative are never frontmatter — `17b:122-129` forbids them); re-validate (MD-14 — no second validation path). + Parallelization: Wave 1 | Blocked by: 2 | Blocks: 4, 5 + References: `ReifiedSpec` shape `src/extract/reify.ts:109-115`; envelope grammar `plans/17b-self-hosting-sessions-1-4.md:122-129`; born-spec envelope examples `specs/carrier/sdp-import.sdp.md:1-8`, `specs/protocol/self-hosting.sdp.md:1-21` (sequence form); relations distinction `check-carrier-interim.mjs:48-49`; MD H1→title mapping `src/extract/markdown-body.ts:125-135,185-195`. + Acceptance criteria: `node ./vitest-test.mjs` passes with the new suite; emitted envelopes parse back through `reifyMarkdownCarrier` to deep-equal `data` (the test asserts it); `npm run check` green. + QA scenarios: happy — the suite's round-trip assertions green (Evidence `.omo/evidence/self-hosting-phase-2/task-3-self-hosting-phase-2.vitest.log`); failure — RED log quoted before implementation (Evidence `.../task-3-self-hosting-phase-2.red.log`) + a hand-corrupted emitted envelope (bad relation key) confirmed refused by `reifyMarkdownCarrier` with `extract/invalid-frontmatter` (Evidence `.../task-3-self-hosting-phase-2.refusal.log`). + Commit: Y | `feat(import): add the pure Markdown document emitter core (envelope + identity)` +- [x] 4. Section emitters — every 17b owner form, fences, and prose ownership (TDD) + What to do / Must NOT do: Extend `emitMarkdownSpec` to emit every section form the 17b heading→section map rules, driven by which optional sections `data` carries: `## Intent` (owned prose → `intent.description`; `- actor/problem/outcome/value:` at most once each; `- risk/assumption:` repeated; `### Open questions` with `- [blocking]`/`- [non-blocking]`) · `## Behavior` (`- rule:`/`- flow:` repeated) · `## Rule` · `## Workflow` · `## Contract` (plain `- TEXT` entries per the interim contract floor) · `## Example space` (owned prose + exactly one `gwt-vocabulary` fence rendering the declared Given/And/When/And/Then/And slots via the same slot text the parser's `parseSlots` accepts) · one `gwt` fence immediately after the `## Intent` block for `kind: example` (ordered steps; every bound slot rendered as literal text — partial bindings like `{n: 0}` render the literal `0`) · `## Constraints` (the one-entry form: `- statement:` required, `- flavor/target/measurableBy:` at most once) · `## Model` (`- **TERM** — DEFINITION`) · `## Design` and `## UI` (`- KEY: TEXT`, lower-camel ASCII keys, authored order) · `## Decision` (`- context/decision:` once, `- rationale/alternative/consequence:` repeated) · `## Verification — MODE` (MODE exactly manual/reviewed/contract/executable). Prose ownership by construction: every prose slot lands under its owning heading — unowned prose must be unrepresentable in the emitter's output path (MD-19). Idiomatic formatting: one blank line after the frontmatter closer and around every H2, fences opened/closed in column 1 with no attributes — the emitted bytes must look agent-authored, matching the born specs' style. TDD: RED first with one fixture per section form (mirror the checkout specs' real content — they exercise every form), then green. Must NOT: invent content (no slot, no line); emit multiple `gwt` fences or multiple constraints entries (the phase-1 caps stand until ruled otherwise); reorder authored arrays. + Parallelization: Wave 1 | Blocked by: 3 | Blocks: 5, 8 + References: the full 17b map `plans/17b-self-hosting-sessions-1-4.md:135-156`; section shapes `src/model/sections.ts:30-120`; the real content to mirror `examples/checkout-v1/specs/orders/create-order.sdp.ts:9-45` (exampleSpace), `create-order-valid-cart.sdp.ts:3-46` (gwt + verification), `order-latency-constraint.sdp.ts:3-21` (constraints), `order-model.sdp.ts:3-23` (model terms), `decisions/order-lifecycle.sdp.ts:3-23` (decision); born-spec style `specs/decisions/plain-language-references.sdp.md:1-18`; prose-ownership law `specs/carrier/prose-ownership-rule.sdp.md`. + Acceptance criteria: for all 11 checkout specs, `emitMarkdownSpec(reifyTypeScriptCarrier(read(file)))` re-parsed by `reifyMarkdownCarrier` yields deep-equal `data` (the suite asserts all 11 — this is the authored-level round trip wired in tests, not yet the harness); `npm run check` green. + QA scenarios: happy — 11/11 authored-level round trips green (Evidence `.omo/evidence/self-hosting-phase-2/task-4-self-hosting-phase-2.vitest.log`); failure — RED log quoted (Evidence `.../task-4-self-hosting-phase-2.red.log`) + delete a required `- statement:` from emitted constraints output, confirm `reifyMarkdownCarrier` refuses (Evidence `.../task-4-self-hosting-phase-2.refusal.log`). + Commit: Y | `feat(import): emit every ruled section form with prose ownership` +- [x] 5. Orchestration — `src/import/import.ts`: `importTypeScriptSpec` + `import/*` findings (TDD) + What to do / Must NOT do: Add `importTypeScriptSpec(sourceText: string, relativePath: string): ImportResult` where `ImportResult = { emitted?: { path: string; content: string }; findings: Finding[] }` — pure, fs-free. Behavior: run `reifyTypeScriptCarrier(sourceText, relativePath)`; if it yields findings at error severity, return `{ findings }` with the reifier's `extract/*` findings passed through UNCHANGED (single-sourced finding currency) plus a wrapping `import/refusal` info noting which file refused; classification is by the reification's **content, never by count coincidence**: `import/pack-unsupported` only when `CarrierReification.packs` is non-empty and `specs` is empty (a genuine pack-only module — packs stay TS by ruling, owner §6.4); zero specs AND zero packs AND zero error findings → `import/empty` (an empty or exports-less module — never misclassified as a pack, so finding currency never lies); a module yielding both specs and packs emits the spec content and reports `import/pack-unsupported` at info severity for the pack portion (never silently dropped); on success return `emitted.path` = the sibling `.sdp.md` path (same relative path, suffix swapped) and `emitted.content` = `emitMarkdownSpec(...)` per spec — one spec per file is today's carrier reality (Markdown produces one spec per document, `src/extract/markdown.ts:246-270`); if a TS file ever yields multiple specs, return `import/unsupported-construct` naming the condition (never guess a split). Export `importFindingIds = { refusal: "import/refusal", packUnsupported: "import/pack-unsupported", targetExists: "import/target-exists", unsupportedConstruct: "import/unsupported-construct", empty: "import/empty" } as const` (`targetExists` is consumed by the CLI layer, todo 7). Never throws — every failure is a finding (owner §6.2). TDD: RED first — healthy spec, refusing spec (non-static envelope), pack module, empty module, multi-spec module, **and the pass-through identity case: a fixture whose reifier yields non-error findings (e.g. a non-static optional-section property → `extract/non-static-section` warning) asserts every pass-through finding keeps its exact `validatorId`, `file`, and `line` — never rewritten into `import/*` IDs** — then green. Must NOT: touch `src/extract/`; import `node:fs`; swallow reifier findings into generic messages (the original `validatorId`, `file`, `line` survive). + Parallelization: Wave 1 | Blocked by: 3, 4 | Blocks: 6, 7, 8 + References: `reifyTypeScriptCarrier` `src/extract/carrier.ts:55-82`; `CarrierReification` shape (specs/packs/findings) at `src/extract/carrier.ts:14`; `Finding` shape `src/validate/contracts.ts`; finding-ID const pattern `src/extract/reify.ts:53-64`; one-spec-per-document `src/extract/markdown.ts:246-270`; MD-14 one-validation-path `docs/concept/DECISIONS.md:42`. + Acceptance criteria: suite green for the six scenarios (healthy, refusal, pack, empty, multi-spec, pass-through identity); `npm run check` green. + QA scenarios: happy — healthy fixture returns emitted content identical to `emitMarkdownSpec` output, and the pass-through test shows `extract/*` findings byte-identical in `validatorId`/`file`/`line` (Evidence `.omo/evidence/self-hosting-phase-2/task-5-self-hosting-phase-2.vitest.log`); failure — RED log quoted (Evidence `.../task-5-self-hosting-phase-2.red.log`) + a non-static-envelope fixture returns `extract/non-static-envelope` + `import/refusal` and NO emitted key (Evidence `.../task-5-self-hosting-phase-2.refusal.log`) + an empty module returns `import/empty`, never `import/pack-unsupported` (Evidence `.../task-5-self-hosting-phase-2.empty.log`). + Commit: Y | `feat(import): add the fs-free TS→Markdown orchestration with import findings` +- [x] 6. Public barrel — export the import surface + pin the public API (TDD) + What to do / Must NOT do: Add to `src/index.ts` (current surface `src/index.ts:1-24`): `emitMarkdownSpec`, `importTypeScriptSpec`, `importFindingIds`, and the `ImportResult`/emitted types (type exports). Add a public-surface test asserting these names resolve from the package root and are functions/const-objects of the expected shape (mirror how `reifyTypeScriptCarrier`/`deriveGraph` are directly exported today). Must NOT: export any fs-touching helper (the CLI's fs layer is not public); change existing export names or shapes (the installed-package contract is pinned downstream); add a default export. + Parallelization: Wave 1 | Blocked by: 5 | Blocks: 11 | Can parallelize with: 7, 8 + References: barrel `src/index.ts:1-24`; package exports map `package.json:11-29`; tsup build entry (`package.json:35` → `tsup.config.*`); the owner ruling "pure emit on the barrel" (draft Decisions 11). + Acceptance criteria: the surface test green (`emitMarkdownSpec`, `importTypeScriptSpec`, `importFindingIds` importable from `src/index.ts`); `npm run build` emits the names in `dist/index.d.ts` (grep it); `npm run check` green. + QA scenarios: happy — `grep -c "emitMarkdownSpec\|importTypeScriptSpec\|importFindingIds" dist/index.d.ts` ≥ 3 after `npm run build` (Evidence `.omo/evidence/self-hosting-phase-2/task-6-self-hosting-phase-2.barrel.log`); failure — RED log quoted (surface test failing before the export) (Evidence `.../task-6-self-hosting-phase-2.red.log`). + Commit: Y | `feat(import): export the emit/import surface from the package barrel` +- [x] 7. The `sdp import` CLI verb — write-beside, `--dry-run`, refusal-honest (TDD) + What to do / Must NOT do: Add the fourth verb to `src/cli/sdp.ts`. Usage line: `sdp import [--dry-run]`. Argument parser `parseImportArgs` (new, mirroring `parseBuildArgs` at `src/cli/sdp.ts:103-172`): one or more positional paths (files or roots, resolved against cwd), `--dry-run` flag, unknown flags rejected with `sdp import: ...` on stderr + exit 1 (the `--exclude --foo` diagnostics lesson — name the offending flag). Behavior per resolved `*.sdp.ts` file (roots scan recursively; `*.pack.sdp.ts` yields `import/pack-unsupported` and continues): read source, run `importTypeScriptSpec`; if the sibling `.sdp.md` already exists, emit `import/target-exists` (never overwrite — no force flag in phase 2); otherwise write it (write-beside). `--dry-run`: print `=== ===` + the would-be content to stdout per file, write nothing. Import **never deletes** the `.sdp.ts` (deletion is the migration's own step, todo 13). Exit codes: 0 when every requested file emitted (or would emit, dry-run); 1 when any error-severity finding or any operational error (unreadable file, EACCES on write — one stderr line, never a stack trace). Findings rendered with the one `formatFinding` (`src/cli/sdp.ts:87-94`). Add the verb to `SDP_HELP_TEXT` (`src/cli/sdp.ts:17-42`) with the same documentation density as the other verbs (arguments, output mode, refusal-never-throw, exit codes) and to the dispatch guard at `src/cli/sdp.ts:526`. For deterministic error-path coverage, extend the injection seam: an `ImportHooks`-style hook object (readFileSync/writeFileSync/existsSync/importTypeScriptSpec substitution) riding the existing `CliHooks` discipline (`src/cli/sdp.ts:49-62` — deterministic producers substitute unreachable branches, never a chmod trick). TDD: RED first — help text mentions import, dry-run writes nothing, write-beside lands, target-exists refuses, pack refuses, bad flag exits 1, refusal exits 1 with findings — then green. **In the same change, land the load-bearing glossary amendment** (review-folded, Option A — the shipped verb must not contradict the ratified glossary for three sessions): rewrite the `sdp import` row at `CONTEXT.md:150` from "the one-way gen-1 `.feature` converter: parser half carrier-neutral, document emitter authored once in the winning carrier" to the multi-adapter verb — one import verb, many source adapters, sharing the document emitter authored once in the winning carrier; the TS→`.sdp.md` adapter landed; the gen-1 `.feature` adapter designed-in and deferred (verified safe: no check pins that row's current text — the interim script pins the transition-clause sentence, `check-carrier-interim.mjs:43-44`; the truth script's CONTEXT scan is the TS-mention audit, which this row does not trip). The flip (todo 22) then machine-pins the amended row via the truth-script re-pin. Must NOT: add a second validation path (no `validateGraph` call, no re-parse of the emitted file); add `--exclude`/`--check-clean` (not this verb's surface); delete or rename any source; touch the interim-pinned `CONTEXT.md` resolved entry (`:238-243`) in this todo — only the `:150` glossary row moves. + Parallelization: Wave 2 | Blocked by: 5 | Blocks: 10, 11 | Can parallelize with: 6, 8 + References: dispatch + entry `src/cli/sdp.ts:514-546`; help template `src/cli/sdp.ts:17-42`; arg-parser pattern `src/cli/sdp.ts:103-172`; finding currency `src/cli/sdp.ts:81-94`; hooks seam `src/cli/sdp.ts:44-62`; orchestration contract from todo 5 (`src/import/import.ts`); owner rulings (draft Decisions 2, 10: durable verb · write-beside + dry-run · never deletes). + Acceptance criteria: the import suite green; `node ./dist/cli/sdp.js --help` lists the import verb after `npm run build`; `grep -n "many source adapters" CONTEXT.md` hits the amended row; `npm run check` green. + QA scenarios: happy — CLI-level test: dry-run on a fixture prints content and leaves the tree byte-identical (`git status --porcelain` empty outside test temp dirs) (Evidence `.omo/evidence/self-hosting-phase-2/task-7-self-hosting-phase-2.vitest.log`); failure — RED log quoted (Evidence `.../task-7-self-hosting-phase-2.red.log`) + invoked on an existing `.sdp.md` sibling prints `import/target-exists` and exits 1 (Evidence `.../task-7-self-hosting-phase-2.target-exists.log`) + leave the glossary row unamended, catch the `grep` acceptance failing, amend (Evidence `.../task-7-self-hosting-phase-2.glossary.log`). + Commit: Y | `feat(cli): add the durable sdp import verb (write-beside, --dry-run) + the glossary amendment` +- [x] 8. The round-trip harness — authored-level + graph-level under the delta catalog (TDD) + What to do / Must NOT do: Create `test/import-round-trip.helpers.ts` with two layers. **Authored-level**: `assertAuthoredRoundTrip(tsSourceText, relativePath)` — reify → emit → reify emitted → assert `ReifiedSpec.data` deep-equal (zero prose normalization; any delta is an emitter defect or a recorded ruling, never an ad-hoc ignore). **Graph-level**: `assertGraphRoundTrip(tsCorpusDir, mdCorpusDir)` — run `extract` + `deriveGraph` on both trees, normalize under the pre-declared delta catalog ((1) `file` fields stripped of the `.sdp.ts|-.sdp.md` suffix before compare; (2) physical envelope differences not graph content — graphs compared, never bytes; (3) TS comments not graph content; (4) delivery facts recomputed under the same anchor set — anchors live in `src/`/`test/` files untouched by migration, so facts must match exactly; (5) `schemaVersion` unchanged), then deep-equal `{nodes, edges}` plus per-node `deliveryFacts`. Fixtures: `test/fixtures/import/round-trip/` — a mini-corpus (one behavior with rules, one example with gwt, one decision, one constraint, one model) authored as `.sdp.ts` twins of what the emitter should produce. TDD: the harness's self-tests RED first (a deliberately corrupted MD twin fails the assertion), then green against emitter output. Must NOT: commit goldens (normalize-and-compare in memory, never a stored snapshot); weaken the catalog mid-flight (a needed sixth delta is a §6 ruling recorded in plan 18 §6 before the assertion changes); compare `Pack` nodes (pack manifests are not imported in phase 2). + Parallelization: Wave 2 | Blocked by: 4, 5 | Blocks: 9, 12 | Can parallelize with: 6, 7 + References: `extract` + `deriveGraph` `src/extract/index.ts:178-185`, `src/extract/derive.ts:120-190`; graph schema + `file` convention `src/graph/schema.ts:33-106`; delivery facts derivation `src/extract/derive.ts:179-190`, `src/graph/delivery-facts.ts:9-24`; delta catalog M3 (this plan) + plan 18 §1; fixture conventions `test/fixtures/extract/duplicate-id/` + `test/self-hosting-duplicate-ids.test.ts:214-258`. + Acceptance criteria: harness self-tests + the mini-corpus round trip green; the corrupted-twin negative test fails loudly when reverted-in (Evidence); `npm run check` green. + QA scenarios: happy — mini-corpus graph round trip green (Evidence `.omo/evidence/self-hosting-phase-2/task-8-self-hosting-phase-2.vitest.log`); failure — RED log quoted (Evidence `.../task-8-self-hosting-phase-2.red.log`) + corrupted-twin assertion diff shown (Evidence `.../task-8-self-hosting-phase-2.negative.log`). + Commit: Y | `test(import): add the two-layer round-trip harness under the delta catalog` +- [x] 9. Enrich `spec:carrier.sdp-import` + its round-trip example child (the tracer pattern) + What to do / Must NOT do: Rewrite `specs/carrier/sdp-import.sdp.md` (currently 12 lines at `idea`, full text in the draft Findings) upward only as the floor honestly clears — target `ready`: intent (actor: a coding agent or maintainer; outcome: convert a TS-carrier spec into an idiomatic `.sdp.md` twin; value: the TS DSL survives as import source), behavior rules (write-beside; never deletes; refuses overwrite; refusal-honest findings; consumes the TS reifier — one validation path), verification — executable with criteria (the round-trip harness proves import→extract→equality). Author the example child `specs/carrier/sdp-import.round-trip.sdp.md`: kind `example`, altitude `story`, readiness `ready`, relations `refines` + `verifies` → `spec:carrier.sdp-import`, a `gwt-vocabulary` exampleSpace on the parent + one `gwt` fence on the child naming the round-trip scenario (Given a TS-carrier spec · When `importTypeScriptSpec` runs · Then the emitted Markdown re-parses to an equal graph), verification — executable. Bind it: a test anchor `test:protocol.sdp-import.round-trip` in a new `test/self-hosting-sdp-import.test.ts` using the generated step contract (the duplicate-IDs precedent: `test/self-hosting-duplicate-ids.test.ts:214-258`) asserting the harness's authored-level equality on the fixture. Add both IDs to `specs/self-hosting.pack.sdp.ts` `specs[]` in the same change (pack growth law). Floor check: `ready` requires the evidence clauses — the bound test is the verifier; if any clause fails, hold at `defined` honestly and record why in the docket. Must NOT: state readiness above floor; author delivery facts; skip the pack manifest edit. + Parallelization: Wave 2 | Blocked by: 8 | Blocks: 12 | Can parallelize with: 10, 11 + References: current spec `specs/carrier/sdp-import.sdp.md:1-12`; the tracer template `specs/validation/duplicate-ids.sdp.md` + `specs/validation/duplicate-ids.dual-carrier.sdp.md` + `test/self-hosting-duplicate-ids.test.ts:193-258`; readiness floors `docs/concept/05-validation-and-honesty.md` §3 + `src/validate/readiness-floor.ts`; pack manifest `specs/self-hosting.pack.sdp.ts:1-25`; codegen contract mechanism `test/self-hosting-duplicate-ids.test.ts` (bindExample usage). + Acceptance criteria: `npm run check` green with both specs in the graph and the pack; `honesty/readiness-floor` passes at the stated readiness; the bound test green (the child's `has-verifier` and the parent's `implemented`/`has-verifier` derived — grep `generated/graph.json` for the two IDs and confirm the delivery facts). + QA scenarios: happy — `node ./vitest-test.mjs` green + graph facts grepped (Evidence `.omo/evidence/self-hosting-phase-2/task-9-self-hosting-phase-2.facts.log`); failure — temporarily state the example child at the wrong readiness rung, confirm `honesty/readiness-floor` fails with the named clause, revert (Evidence `.../task-9-self-hosting-phase-2.floor.log`); **drift alarm (the tracer's headline property)** — edit a step string in the example child's `gwt` fence, regenerate (`npm run generate:self-hosting`), confirm the bound contract test reddens naming the drifted step, restore the spec, confirm green (Evidence `.../task-9-self-hosting-phase-2.drift.log`). + Commit: Y | `feat(specs): enrich spec:carrier.sdp-import with its round-trip verifier child` +- [x] 10. Debt cluster — exclude/CLI diagnostics (review-06, owner §6.8) (TDD) + What to do / Must NOT do: Four fixes, each RED-first with a pinned regression: (a) Windows drive-letter absolutes (`C:/…`) in `normalizeExcludes` — currently silent no-ops; make them a loud usage finding (or normalized, per the smallest honest behavior — record the choice in the docket); (b) `--exclude --foo` — currently reported as a missing path; make it a usage error naming the offending flag (`sdp : --exclude expects a path, got --foo`); (c) matcher regression coverage — add the focused test pinning the path-segment boundary of prefix matching (the single most regression-prone line); (d) library-seam exclusion errors worded as CLI flag errors — separate the library diagnostic wording from CLI usage wording. Locate the exact seams first (`parseBuildArgs` + the exclude normalization used by `extract`). Must NOT: change the exclusion semantics (MD-20's strict contract stays); touch the import verb's behavior (same file region — sequence after todo 7, rebase carefully); weaken any existing exclude test. + Parallelization: Wave 2 | Blocked by: 7 | Blocks: 12 | Can parallelize with: 6, 8, 9, 11 + References: review-06 lines 264–267 (the four items) + plan-17 reconciliation rows `plans/17-self-hosting-v1.md:445-468`; `parseBuildArgs` `src/cli/sdp.ts:103-172`; exclude plumbing via `ExtractOptions` (`src/extract/index.ts`); the exclusion contract ruling (MD-20) `docs/concept/DECISIONS.md:48`. + Acceptance criteria: the four regression tests green; `npm run check` green; the docket rows for these four items stamped `done s1` in plan 18 §8. + QA scenarios: happy — `node ./vitest-test.mjs` green incl. the new boundary test (Evidence `.omo/evidence/self-hosting-phase-2/task-10-self-hosting-phase-2.vitest.log`); failure — four RED logs quoted, one per item (Evidence `.../task-10-self-hosting-phase-2.red.log`). + Commit: Y | `fix(cli): loud exclude diagnostics + matcher boundary coverage (review-06 cluster)` +- [x] 11. Installed-package smoke proof for the new public surface + What to do / Must NOT do: Prove the published artifact carries the verb and the barrel exports: `npm pack` → install the tarball into a fresh temp consumer dir (`npm install --prefix `) → in the consumer, run `node_modules/.bin/sdp --help` (import verb listed), `node_modules/.bin/sdp import --dry-run ` (content printed, nothing written), and `node -e "const m=require('@libar-dev/software-delivery-protocol'); if(typeof m.emitMarkdownSpec!=='function'||typeof m.importTypeScriptSpec!=='function'||typeof m.importFindingIds!=='object') process.exit(1)"`. Script it as `test/installed-package-import.smoke.mjs` (or extend the existing proof mechanism if one exists — check `preflight.mjs` and any phase-1 package-proof script first and reuse its harness). Must NOT: publish anything; mutate the consumer outside its temp dir; rely on the repo's own `dist/` (the proof must hit the packed tarball — stale_state discipline). + Parallelization: Wave 2 | Blocked by: 6, 7 | Blocks: 12 | Can parallelize with: 8, 9, 10 + References: `package.json:11-29` (files/exports/bin); `preflight.mjs` (existing generated-state proofs); phase-1 "Public/package API proof" docket row `plans/17-self-hosting-v1.md:445-468`; bin wiring `package.json:14-16`. + Acceptance criteria: the smoke script exits 0 against the packed tarball and its log shows all three assertions; `npm run check` green. + QA scenarios: happy — full smoke log (Evidence `.omo/evidence/self-hosting-phase-2/task-11-self-hosting-phase-2.smoke.log`); failure — temporarily remove `importFindingIds` from the barrel, repack, confirm the smoke exits 1 naming the missing export, revert (Evidence `.../task-11-self-hosting-phase-2.negative.log`). + Commit: Y | `test(cli): prove the installed package carries sdp import and the barrel surface` +- [x] 12. Session 1 close — Design Review evidence pack + gate G1 + What to do / Must NOT do: Assemble the G1 evidence pack: the emitter/CLI test logs, the round-trip harness report, the installed-package smoke log, the enriched `spec:carrier.sdp-import` graph facts, the debt-cluster docket stamps, and the generated Design Review view (`npm run generate:self-hosting`) for the owner to review. Present to the owner; on acceptance, stamp plan 18 §10 gate G1 (`accepted` + date + the session's final SHA) and extend `check-self-hosting-gates.mjs`'s plan-18 block with the SHA; stamp the session's docket rows in §8. Must NOT: start session-2 work before the owner's G1 disposition; mark any watch item resolved (none fired this session — record `watch` states unchanged in the done-record). + Parallelization: Wave 2 | Blocked by: 9, 10, 11 | Blocks: 13 + References: gate-ledger format `plans/17-self-hosting-v1.md:397-410`; gates script mechanics `check-self-hosting-gates.mjs:159-197`; plan 18 §8/§10 (todo 1). + Acceptance criteria: G1 row stamped with owner disposition + SHA; `node ./check-self-hosting-gates.mjs` exits 0; `npm run check` green. + QA scenarios: happy — post-stamp check log (Evidence `.omo/evidence/self-hosting-phase-2/task-12-self-hosting-phase-2.g1.log`); failure — stamp a wrong SHA, confirm the gates script exits 1, fix (Evidence `.../task-12-self-hosting-phase-2.gates-failure.log`). + Commit: Y | `docs(plans): session 1 close — gate G1 accepted (emitter surface)` +- [x] 13. The checkout-v1 migration — all 11 IDs in ONE atomic commit + What to do / Must NOT do: FIRST capture the pre-migration baseline: on the pre-migration tree run `node ./dist/cli/sdp.js build examples/checkout-v1` and copy `examples/checkout-v1/generated/graph.json` to the evidence dir. Then migrate: for each of the 11 checkout spec files run `node ./dist/cli/sdp.js import ` (write-beside), REVIEW every emitted document against the authoring-surface bar (idiomatic under the ruled grammar + prose ownership — if any emitted document is not something an agent could have authored, FIX THE EMITTER, never hand-edit the spec — tracer-bullet discipline; any grammar gap becomes a §6 ruling under fire, recorded when made), then delete the `.sdp.ts` source. All 11 emits + all 11 deletes + any emitter fixes land as ONE commit (the atomicity law — no blessed commit holds both surfaces of an ID). Then prove fidelity: rebuild and run the graph-level round trip (todo 8 harness) of post-migration `examples/checkout-v1` against the baseline — deep-equal under the delta catalog; also run the authored-level assertion on all 11 pairs from git history (`git show HEAD~1:` for the TS originals). The pack manifest `checkout.pack.sdp.ts` stays TS and unchanged (lawful per-ID rule). Confirm `npm run typecheck:examples` still passes (the TS spec modules are gone; nothing should import them — verify with `grep -r "\.sdp" examples/checkout-v1/src examples/checkout-v1/test` that no code imports a spec module; anchors live in impl/test files, untouched). Must NOT: hand-edit emitted Markdown (emitter fix or §6 ruling only); delete the pack manifest; commit the migration in pieces; edit `generated/` (untracked, regenerated in-pipeline). + Parallelization: Wave 3 | Blocked by: 12 | Blocks: 14, 15, 16 + References: the 11 files (todo 2 inventory); baseline/build commands `package.json:44,46-47`; round-trip harness `test/import-round-trip.helpers.ts` (todo 8); example layout `examples/checkout-v1/README.md:16-52`; the atomicity law `plans/18-self-hosting-phase-2-brief.md:108-112` + Commit strategy (this plan). + Acceptance criteria: one commit contains exactly 11 added `.sdp.md` + 11 deleted `.sdp.ts` (+ emitter fixes if any); the graph-level round trip passes (Evidence); `npm run check` green. + QA scenarios: happy — round-trip report 11/11 + full check log (Evidence `.omo/evidence/self-hosting-phase-2/task-13-self-hosting-phase-2.roundtrip.md` and `.../task-13-self-hosting-phase-2.check.log`); failure — deliberately hand-edit one emitted rule line, re-run the authored-level assertion, confirm it fails naming the spec, restore via emitter (Evidence `.../task-13-self-hosting-phase-2.negative.log`). + Commit: Y | `feat(examples): migrate checkout-v1 to the Markdown carrier via sdp import (atomic)` +- [x] 14. Rewrite the checkout README walkthrough for the Markdown surface + What to do / Must NOT do: Rewrite `examples/checkout-v1/README.md` sections to match the migrated tree: `## The layout` (specs now `.sdp.md`; the pack manifest stays `.pack.sdp.ts` — say why in one line: packs are not a kind; the carrier ruling covers spec IDs), `## The walk` (same commands; note `sdp import` as the verb that produced the Markdown surface and remains for any future per-ID TS→MD move), `## Break it on purpose` (experiments re-pointed at Markdown edits — e.g. an unrecognized heading refuses with `extract/unrecognized-heading`), `## Where the concepts live` (unchanged targets). Keep the tracer-bullet framing intact. Must NOT: claim the TS DSL is retired (it survives as import source + per-ID option); touch the interim-pinned strings elsewhere (this README is not one of the interim script's surfaces, but keep the carrier story consistent with the pre-flip rule — the example migrated under the ruled flip procedure; the post-flip anti-misleading pass re-reads this file at todo 22). + Parallelization: Wave 3 | Blocked by: 13 | Blocks: 17 | Can parallelize with: 15, 16 + References: current README structure `examples/checkout-v1/README.md:1,16,54,136,173`; MD refusal IDs (todo 2 references); the surviving-law sentence `check-carrier-interim.mjs:51`. + Acceptance criteria: README reflects the Markdown surface end-to-end; every command in it runs (`sdp build/validate/view examples/checkout-v1`); `npm run check` green. + QA scenarios: happy — run every documented command fresh and paste outputs (Evidence `.omo/evidence/self-hosting-phase-2/task-14-self-hosting-phase-2.walkthrough.log`); failure — deliberately document a wrong refusal ID, catch it in review against `src/extract/markdown-body.ts:72-93`, fix (Evidence `.../task-14-self-hosting-phase-2.review.log`). + Commit: Y | `docs(examples): rewrite the checkout-v1 walkthrough for the Markdown carrier` +- [x] 15. Rule the Design Review debt cluster (decision prompt → ruled fix) (TDD) + What to do / Must NOT do: Put the concrete decision to the owner at this session's Design Review (never "polish later"): **Prompt** — "Design Review rendering, two pre-existing debts (review-06 lines 271–275): (a) dynamic-key ordering follows in-memory insertion order, so a re-parsed `graph.json` can render different bytes; (b) escaping stops at narrative/descriptions — titles, rules, terms, criteria render unescaped. Options: (1) escape every rendered field uniformly + sort dynamic keys lexicographically at render (recommended — deterministic from any parse source; uniform safety; tiny output churn on the current corpus); (2) escape prose slots only + sort keys (keeps the TS-carrier HTML-in-title posture, narrows the fix); (3) record a trusted-authored policy and change nothing (names the risk accepted)." On the owner's ruling, implement the ruled option with determinism tests: a permutation test proving byte-identical renders from shuffled insertion orders (the GWT permutation precedent fixed in review-06's addendum), and escaping tests for the newly covered fields. Record the ruling in plan 18 §6 and the docket. Must NOT: change rendered information content (ordering/escaping only); let the escaping policy differ per field after the ruling (uniform per the ruled option). + Parallelization: Wave 3 | Blocked by: 13 | Blocks: 17 | Can parallelize with: 14, 16 + References: review-06 lines 271–277 + the fixed-permutation precedent at its addendum (lines 335–357); the renderer `src/projections/` (`renderDesignReview` — exact file via `grep -r "renderDesignReview" src/projections`); Design Review spec surface `docs/concept/06-consumers-and-projections.md` §5. + Acceptance criteria: the ruling recorded with owner disposition; ruled fix tests green (permutation byte-equality + escaping); `npm run check` green; docket rows for both items stamped. + QA scenarios: happy — `node ./vitest-test.mjs` green incl. permutation test (Evidence `.omo/evidence/self-hosting-phase-2/task-15-self-hosting-phase-2.vitest.log`); failure — RED log quoted for the ruled fix (Evidence `.../task-15-self-hosting-phase-2.red.log`). + Commit: Y | `fix(projections): rule and land the Design Review ordering/escaping debts` +- [x] 16. Watch-item dispositions + under-fire rulings log (session record) + What to do / Must NOT do: Record in plan 18 §6 and §8: (a) the table-sugar verdict — did the migration force it? (Expected verdict: UNFIRED — checkout models multi-case as sibling `example` specs without tables; if fired, the ruling text + the grammar amendment); (b) the state of all five named watch items (table sugar · single-literal vocabulary form · multi-entry constraint form · array-section prose sub-owner · Markdown Pack syntax) — each `fired (ruling: …)` or `watch` with one line why the corpus did/didn't force it; (c) every under-fire ruling made during todos 13–15 with its three-part-test disposition (durable candidates noted for the fold's lean-registry admission test). Must NOT: resolve a watch item without a recorded reason (acceptance 6); admit a ruling to the durable registry that fails the three-part test. + Parallelization: Wave 3 | Blocked by: 13 | Blocks: 17 | Can parallelize with: 14, 15 + References: watch items `plans/18-self-hosting-phase-2-brief.md:57-64` + acceptance 6 `:362-364`; point-per-example semantics (MD-17) `docs/concept/DECISIONS.md:45`; plan-17 §3 rulings format `plans/17-self-hosting-v1.md:265-284`. + Acceptance criteria: plan 18 §6/§8 carry all five items' states + every under-fire ruling; `npm run check` green. + QA scenarios: happy — the recorded dispositions reviewed against acceptance 6 (Evidence `.omo/evidence/self-hosting-phase-2/task-16-self-hosting-phase-2.watch-items.md`); failure — attempt to close the session with an item unrecorded, catch it against the acceptance-6 checklist, fix (Evidence `.../task-16-self-hosting-phase-2.checklist.log`). + Commit: Y | `docs(plans): record session-2 watch-item dispositions and rulings under fire` +- [x] 17. Session 2 close — two-layer fidelity verdict + delta catalog done-record + gate G2 + What to do / Must NOT do: Assemble the G2 evidence pack: the migration round-trip report (todo 13), the emitted 11 documents as the authoring-surface exhibit, the README rewrite, the DR-cluster ruling (todo 15), the watch-item record (todo 16), and the generated Design Review view. Land the pre-declared delta catalog in the session's done-record inside plan 18 §7 (the brief requires the catalog in the done-record, never an ad-hoc test ignore — it is already pinned in §1; §7 records it as executed). Present to the owner; on acceptance, stamp G2 (owner disposition + date + SHA) in plan 18 §10 and the gates script's plan-18 block; stamp docket rows. Must NOT: start session 3 before the owner's G2 disposition; collapse the authoring-surface bar into graph equality (they are two layers, judged separately). + Parallelization: Wave 3 | Blocked by: 14, 15, 16 | Blocks: 18 + References: acceptance 2 `plans/18-self-hosting-phase-2-brief.md:344-349`; gate mechanics (todo 12 references). + Acceptance criteria: G2 stamped; done-record carries the delta catalog; `node ./check-self-hosting-gates.mjs` exits 0; `npm run check` green. + QA scenarios: happy — post-stamp check log (Evidence `.omo/evidence/self-hosting-phase-2/task-17-self-hosting-phase-2.g2.log`); failure — omit the catalog from the done-record, catch it against acceptance 2's wording, fix (Evidence `.../task-17-self-hosting-phase-2.catalog.log`). + Commit: Y | `docs(plans): session 2 close — gate G2 accepted (migration under fire)` +- [x] 18. Debt cluster — grammar hardening (review-06 lines 251–262) (TDD) + What to do / Must NOT do: Eight fixes in the Markdown parser/reifier, each RED-first: (a) YAML 1.2 scalar spellings (`1e3`, `0x1F`, `-.inf`) — correct the non-string-scalar classification so refusal messages classify right; (b) yaml-native error line numbers vs rebased `finding.line` off-by-one — pin the rebasing; (c) `...` document-end accepted before a later `---` closer — pin acceptance/refusal explicitly (choose the stricter honest behavior, record in the docket); (d) non-mapping-root refusal discards previously accumulated findings — preserve them; (e) depth/node-cap finding flood — bound to one finding per offending node (or the recorded bounded policy); (f) heading/GWT strictness — the example-kind `gwt` fence must sit immediately after the Intent block; `### Open questions` trailing whitespace accepted per the frozen trim rule; H1/H2 titles ending in `#` — pin the ruled behavior; (g) duplicate `When` double-report — single finding; remove the dead `mapOwner` branch; (h) `reifyMarkdownCarrier` catch-all totality — add the wrapper backstop mirroring the TS reifier's (`src/extract/carrier.ts:55-82`) so any unexpected throw becomes a finding, never a crash. Keep every fix inside the ruled grammar — grammar changes are §6 rulings, recorded when made. Must NOT: change finding IDs (currency stability); alter the frozen 17b grammar without a recorded ruling; batch all eight into one untestable blob (one RED→GREEN per item, commits may group related items a–d / e–h). + Parallelization: Wave 4 | Blocked by: 17 | Blocks: 19 + References: review-06 lines 251–262 verbatim items; parser files `src/extract/markdown.ts` (orchestration, catch-all site), `markdown-yaml-policy.ts` (a,b,c,d), `markdown-support.ts` (e — the cap), `markdown-body.ts`/`markdown-body-content.ts` (f,g); TS wrapper precedent `src/extract/carrier.ts:57-80`. + Acceptance criteria: each item has its RED→GREEN pair; `node ./vitest-test.mjs` green; `npm run check` green; docket rows stamped `done s3`. + QA scenarios: happy — full suite green (Evidence `.omo/evidence/self-hosting-phase-2/task-18-self-hosting-phase-2.vitest.log`); failure — eight RED logs quoted, one per item (Evidence `.../task-18-self-hosting-phase-2.red.log`) + a thrown-parser probe (malformed input that previously crashed) now yields a finding (Evidence `.../task-18-self-hosting-phase-2.totality.log`). + Commit: Y | `fix(extract): land the grammar-hardening cluster (review-06)` +- [x] 19. Execute the refusal-parity matrix against the settled grammar (TDD) + What to do / Must NOT do: For each of the 10 TS refusal classes, build a probe pair (a `.sdp.ts` fixture that trips the class; a `.sdp.md` twin attempting the equivalent violation) in `test/fixtures/import/parity/`, run both reifiers, and record the cell: `same-class finding (ID)` or `named non-claim (why the class does not transfer — e.g. non-static-section's graceful tier is a TS-carrier granularity law; the MD carrier is deliberately all-or-nothing per document, 04 §1)` or `gap → §6 ruling`. The executed matrix fills plan 18 §3's skeleton cells with finding IDs and evidence links. TDD: the probe suite asserts each cell's recorded expectation — a cell whose expectation is `same-class finding` fails if the MD probe doesn't refuse with that class. Must NOT: measure against pre-todo-18 behavior (the debt fixes settled it — parity is paid once); claim parity for any cell marked non-claim; expand the matrix to every ts-morph diagnostic (bounded to authored fidelity by design). + Parallelization: Wave 4 | Blocked by: 18 | Blocks: 20 + References: TS classes `src/extract/reify.ts:53-64`; MD finding IDs (todo 2 references); the per-carrier granularity law `docs/concept/04-authoring-and-binding.md:52`; matrix skeleton plan 18 §3 (todo 1). + Acceptance criteria: the probe suite green with all 10 cells recorded; plan 18 §3 cells filled; `npm run check` green. + QA scenarios: happy — executed matrix + suite log (Evidence `.omo/evidence/self-hosting-phase-2/task-19-self-hosting-phase-2.matrix.md`); failure — flip one cell's expectation, confirm the suite fails naming the cell, revert (Evidence `.../task-19-self-hosting-phase-2.negative.log`). + Commit: Y | `test(extract): execute the bounded TS↔Markdown refusal-parity matrix` +- [x] 20. Land the hardening claim on the parser specs + What to do / Must NOT do: Discharge the pinned non-claim on `spec:carrier.markdown-parser` (and `spec:carrier.envelope-contract` if implicated): either (a) the like-for-like refusal-parity claim with the executed matrix as pinned evidence (expected outcome if every cell is same-class or named non-claim with the non-claims enumerated in the claim text), or (b) a revised, explicitly scoped non-claim naming the exact non-parity classes — never a silent lapse. The claim text lands in the spec body (the phase-1 non-claim discipline); readiness re-stated only where the floor clears. Update the spec's verification section to reference the matrix evidence. Record the choice (a/b) in plan 18 §6. Must NOT: overclaim ("full parity" when any cell is a named non-claim — enumerate them); raise readiness without floor evidence; leave the old non-claim text alongside the new claim (replace it). + Parallelization: Wave 4 | Blocked by: 19 | Blocks: 21 + References: parser spec `specs/carrier/markdown-parser.sdp.md:1-18` (current non-claim text); envelope spec `specs/carrier/envelope-contract.sdp.md`; acceptance 3 `plans/18-self-hosting-phase-2-brief.md:350-352`; Gate-4 C2-parity obligation `plans/17-self-hosting-v1.md:410`. + Acceptance criteria: the parser spec carries the new claim text with the matrix reference; `npm run check` green (floor-honest); acceptance-3 wording satisfied (claim on the specs themselves). + QA scenarios: happy — spec diff + check log (Evidence `.omo/evidence/self-hosting-phase-2/task-20-self-hosting-phase-2.claim.md`); failure — leave both old and new claim texts, catch the contradiction in review against acceptance 3, fix (Evidence `.../task-20-self-hosting-phase-2.review.log`). + Commit: Y | `feat(specs): resolve the parser hardening non-claim with pinned parity evidence` +- [x] 21. Session 3 close — gate G3 + What to do / Must NOT do: Assemble the G3 evidence pack: the grammar-hardening RED→GREEN log, the executed parity matrix, the parser-spec claim text, and the generated Design Review view. Present to the owner; on acceptance, stamp G3 (owner disposition + date + SHA) in plan 18 §10 and the gates script's plan-18 block; stamp docket rows. Must NOT: start the flip before the owner's G3 disposition. + Parallelization: Wave 4 | Blocked by: 20 | Blocks: 22 + References: gate mechanics (todo 12 references). + Acceptance criteria: G3 stamped; `node ./check-self-hosting-gates.mjs` exits 0; `npm run check` green. + QA scenarios: happy — post-stamp check log (Evidence `.omo/evidence/self-hosting-phase-2/task-21-self-hosting-phase-2.g3.log`); failure — stamp without the claim landed, catch against the G3 meaning text, fix (Evidence `.../task-21-self-hosting-phase-2.review.log`). + Commit: Y | `docs(plans): session 3 close — gate G3 accepted (hardening baseline)` +- [x] 22. THE FLIP — post-flip operative records + script sweep + anti-misleading pass (ONE commit) + What to do / Must NOT do: One atomic commit carrying, in step: (a) **MD-18's Decision text** in `docs/concept/DECISIONS.md` — the transition clause discharged: replace the interim sentence with the post-flip operative sentence: *"Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option."* (keep the surviving law "one canonical surface per ID, no mixing" and the MD-15 "re-pointed, not repealed" wording — both stay pinned); (b) **`CONTEXT.md`** — rewrite the carrier resolved entry (`CONTEXT.md:238-243`) to post-flip (current law only; the transition history lives in git); the `sdp import` glossary row (`:150`) was already amended at todo 7 — verify the amendment stands verbatim, and in the truth-script re-pin below add a standing pin for the amended row's key phrase (`many source adapters`) so the load-bearing amendment is machine-pinned from this commit onward (brief §3.1 binding (a) — on the record, never a silent add-on); (c) **`AGENTS.md`** — replace the interim-carrier-rule status clause with the post-flip sentence (edit `AGENTS.md`, never the `CLAUDE.md` symlink); (d) **script sweep** — rename `check-carrier-interim.mjs` → `check-carrier-rule.mjs`: pins replaced by the post-flip sentence across the same three surfaces (DECISIONS/CONTEXT/AGENTS), the relations-distinction and surviving-law pins retained, the plan-17 docket-row check retired (its rows are closed; plan-18's docket is enforced by the gates script), and a full reference sweep in the same commit — the script's own header/usage comments, the comment at `check-carrier-truth.mjs:30`, and a repo-wide `grep -rn "check-carrier-interim" .` cleaned of stale references; re-pin `check-carrier-truth.mjs` — interim/Gate-4 strings in CLAIMS replaced by post-flip pins, the interim sentence added to OBSOLETE, the TS-mention RULES classification audit retained (TS mentions stay lawful: import source, per-ID option, fixtures, DSL not retired); retire/re-point the Gate-4 phase-2 pins in `check-self-hosting-gates.mjs:187-197` (the phase-2 disposition is now executing — replace with the plan-18 block's standing checks); (e) **anti-misleading pass** — repair every remaining interim-rule claim, inventory seeded from the truth script's audit map: `docs/concept/00` (the interim sentence + `TypeScript-canonical`-era wording), `docs/concept/README.md` (the interim sentence), `docs/concept/07` (Carrier addendum), `jtbd-stories/01` + `jtbd-stories/README.md` (carrier phrasing stays carrier-neutral — re-check), MD-15's wording post-flip (its rationale carries to the `.sdp.md` sibling — confirm the text says so), root `README.md` if it carries carrier/CLI claims (add the `import` verb to any CLI section), package docs; (f) **`04` §1 repair** (`docs/concept/04-authoring-and-binding.md:63`) — replace the interim sentence with the post-flip sentence AND repair "A per-ID canonical-surface config decides which surface is canonical for a spec; the other is a generated read-only view." to aspirational wording (the owner-ruled disposition: file-existence-only is the realization — e.g. "A per-ID canonical-surface config is a designed-for, deferred mechanism (ASPIRATIONAL); today, which file exists decides the canonical surface — both surfaces for one ID is the duplicate finding"). Verify: `node ./check-carrier-rule.mjs`, `node ./check-carrier-truth.mjs`, `node ./check-self-hosting-gates.mjs`, and `npm run check` all green at the commit. Must NOT: land (a)–(f) across multiple commits (docs-agree checks must never see a mixed state); delete the TS DSL or its reifier; touch the dual-carrier fixtures; claim the gen-1 `.feature` adapter exists. + Parallelization: Wave 5 | Blocked by: 21 | Blocks: 23 + References: interim pins `check-carrier-interim.mjs:32-63`; truth audit map `check-carrier-truth.mjs:53-272` + OBSOLETE `:330-348` + RULES `:378-644`; gates pins `check-self-hosting-gates.mjs:187-197`; the exact strings to replace: `CONTEXT.md:238-243` (`:150` verified, already amended at todo 7 — its key phrase gets a standing truth-script pin here), `docs/concept/04-authoring-and-binding.md:63`, AGENTS.md status block, `docs/concept/README.md` (write-path paragraph), `docs/concept/00-vision-scope-and-mvp-boundary.md` §3–§4, `docs/concept/07-mvp-roadmap-and-open-questions.md` (Carrier addendum); the post-flip sentence (this plan M6); brief §3 tranche-1.4 `plans/18-self-hosting-phase-2-brief.md:123-140`. + Acceptance criteria: a single commit touches all of (a)–(f); the three audit scripts + `npm run check` exit 0; and the anti-misleading sweep passes: `grep -rn "until the ruled flip" --include="*.md" --exclude-dir=plans --exclude-dir=reviews --exclude-dir=.omo --exclude-dir=node_modules --exclude-dir=dist .` returns **empty** (frozen/historical records — `plans/` incl. the brief and plans 16/17/17b, `reviews/`, `.omo/` — lawfully retain past-tense quotes; they are inputs and archives, never live claims — the standing policy: past-tense historical mentions in plans/reviews/.omo are lawful; operative present-tense interim claims on active surfaces are forbidden), AND each named active surface is verified individually to carry the post-flip wording instead: `AGENTS.md`, `CONTEXT.md`, `docs/concept/DECISIONS.md` (MD-18 region), `docs/concept/00-…`, `04-…`, `07-…`, `README.md` (concept), `jtbd-stories/`, root `README.md`, package docs. + QA scenarios: happy — post-commit audit-script logs + full check log (Evidence `.omo/evidence/self-hosting-phase-2/task-22-self-hosting-phase-2.flip.log`); failure — stage only the CONTEXT.md half, run `node ./check-carrier-rule.mjs`, confirm exit 1 naming DECISIONS.md/AGENTS.md disagreement, unstage (Evidence `.../task-22-self-hosting-phase-2.half-flip.log`). + Commit: Y | `feat(carrier): the canonical-default flip — Markdown by default, TS as import source and per-ID option` +- [x] 23. Flip verification — full clean-clone proof + acceptance 1–3, 8 evidence + What to do / Must NOT do: Prove the flip on a pristine copy: fresh `git clone` (or `git worktree add`) of the branch into a temp dir → `npm ci` → full `npm run check` → capture the log. Assemble the acceptance evidence: (1) no TS-canonical product spec ID under blessed roots — `find specs examples/checkout-v1/specs -name "*.sdp.ts" ! -name "*.pack.sdp.ts"` returns empty; pack manifests TS (2 files); dual-carrier fixtures intact (`test/fixtures/extract/duplicate-id/` keeps both surfaces); (2) round-trip fidelity already proven (todo 13 report re-attached) + installed-package proof re-run post-flip (todo 11 script); (3) the hardening claim landed (todo 20); (8) `sdp import` documented — the amended glossary row, help text, README mentions; `node_modules/.bin/sdp import --dry-run` works from the packed tarball. Must NOT: treat the dev-tree check as sufficient (the proof is the clean clone — preflight's generated-state isolation makes this meaningful); skip the fixture-intact assertion. + Parallelization: Wave 5 | Blocked by: 22 | Blocks: 24 + References: acceptance 1–3, 8 `plans/18-self-hosting-phase-2-brief.md:338-343,368-371`; preflight `preflight.mjs:18-37,125-172`; fixture paths (todo 8 references). + Acceptance criteria: clean-clone `npm run check` exits 0 (log captured); the acceptance 1–3, 8 evidence file assembles every item with its command output. + QA scenarios: happy — clean-clone log + evidence assembly (Evidence `.omo/evidence/self-hosting-phase-2/task-23-self-hosting-phase-2.clean-clone.log` and `.../task-23-self-hosting-phase-2.acceptance.md`); failure — delete one fixture twin in the clone, confirm the evidence assembly's fixture check fails, restore (Evidence `.../task-23-self-hosting-phase-2.fixture.log`). + Commit: N (evidence only; no repo change beyond evidence dir) +- [~] 24. HARD phase-internal owner gate — G4 (the flip) + What to do / Must NOT do: Present the owner the G4 evidence pack: the flip commit's audit logs, the clean-clone proof, the acceptance 1–3 + 8 assembly, and the generated Design Review view. The owner verifies acceptance criteria 1–3 and 8 by name. On acceptance, stamp G4 (owner disposition + date + SHA) in plan 18 §10 and the gates script's plan-18 block. **Corpus work (wave 6+) is forbidden before this stamp** — expansion pressure never recreates the three-state carrier world (brief §9). Must NOT: start any tranche-2/3 authoring before the owner's G4 disposition; treat G4 as a formality (it is the phase's hard gate). + Parallelization: Wave 5 | Blocked by: 23 | Blocks: 25, 26 + References: the hard-gate rule `plans/18-self-hosting-phase-2-brief.md:442-444`; acceptance 1–3, 8 (todo 23 references). + Acceptance criteria: G4 stamped `accepted` with SHA; `node ./check-self-hosting-gates.mjs` exits 0; `npm run check` green. + QA scenarios: happy — post-stamp log (Evidence `.omo/evidence/self-hosting-phase-2/task-24-self-hosting-phase-2.g4.log`); failure — attempt wave-6 authoring before the stamp, confirm the plan's own gate ordering blocks it (documented check against the DAG), revert (Evidence `.../task-24-self-hosting-phase-2.gate-order.log`). + Commit: Y | `docs(plans): session 4 close — HARD gate G4 accepted (the canonical flip)` +- [x] 25. Corpus wave 1a — the model subsystem specs (6 specs, refine-target minimum part 1) + What to do / Must NOT do: Author under the per-wave modeling policy (Execution strategy §policy): `specs/model/core-model.sdp.md` (`spec:model.core-model` — model, feature, defined; refines `spec:protocol.self-hosting`; carries `02` §1 the `Spec` primitive + §2 the three descriptors incl. the readiness-vs-delivery-facts distinction — D1's future carrying surface; anchors: `src/model/spec.ts`, `src/model/descriptors.ts`) · `specs/model/spec-sections.sdp.md` (`spec:model.spec-sections` — model, feature, defined; refines `spec:model.core-model`; carries `02` §3 sections + the typing-law content (MD-11's governed content) + verifier semantics §3; anchors: `src/model/sections.ts`, `src/validate/readiness-floor.ts`) · `specs/model/relations.sdp.md` (`spec:model.relations` — model, feature, defined; refines `spec:model.core-model`; carries `02` §6 relations; anchors: `src/model/relations.ts`) · `specs/model/stable-ids.sdp.md` (`spec:model.stable-ids` — rule, story, defined; refines `spec:model.core-model`; carries `02` §5 stable IDs (P6); anchors: the ids module — find via `grep -rn "parseId" src/model`) · `specs/model/pack-aggregate.sdp.md` (`spec:model.pack-aggregate` — model, story, defined; refines `spec:model.core-model`; carries `02` §4 the `Pack` — D3's future carrying surface; anchors: `src/model/pack.ts`) · `specs/model/anchors.sdp.md` (`spec:model.anchors` — model, feature, defined; refines `spec:model.core-model`; carries `04` §2 generic source anchors — MD-7/MD-8's domain; anchors: `src/model/anchors.ts`, the anchor extraction seam). Add all six IDs to `specs/self-hosting.pack.sdp.ts` in the same change (`modelRefs` gains `spec:model.core-model` if it is a genuine domain vocabulary carrier — decide honestly per the pack-coherence rule: modelRefs target model specs). Coverage ledger: one row per spec (plan 18 §5) naming the `02`/`04` sections each carries. Anchor honesty: anchors are code-level `codeAnchor` bindings in the named source files (the existing anchor mechanism — check how current anchors are declared, e.g. in `src/` files via the anchor-constant form, `examples/checkout-v1/src/orders/create-order.use-case.ts` precedent); `implemented` derives. Must NOT: author readiness above floor; add decorative anchors; restate the MD rulings as spec content (the fold carries the laws — these specs carry the model facts the laws govern; cross-link with `refines`/`decidedBy` only when the fold lands). + Parallelization: Wave 6 | Blocked by: 24 | Blocks: 27 | Can parallelize with: 26 + References: concept sections `docs/concept/02-core-model.md` §1–§6 (heading map verified at planning), `docs/concept/04-authoring-and-binding.md` §2; model sources `src/model/spec.ts`, `descriptors.ts`, `sections.ts`, `relations.ts`, `pack.ts`, `anchors.ts`; anchor-constant precedent `examples/checkout-v1/README.md:32-49`; existing corpus style `specs/model/protocol-domain.sdp.md:1-18`; pack manifest `specs/self-hosting.pack.sdp.ts:1-25`; pack-coherence validator `src/validate/validators.ts:32-44`. + Acceptance criteria: six specs parse into the graph (`npm run generate:self-hosting` shows them); each has ≥1 precise anchor and `implemented` derived where claimed; coverage rows added; `npm run check` green. + QA scenarios: happy — generated graph greps for the six IDs + anchors (Evidence `.omo/evidence/self-hosting-phase-2/task-25-self-hosting-phase-2.graph.log`); failure — author one spec at `ready` without evidence clauses, confirm `honesty/readiness-floor` fails naming the clause, set honest readiness (Evidence `.../task-25-self-hosting-phase-2.floor.log`). + Commit: Y | `feat(specs): corpus wave 1a — the model subsystem (refine-target minimum)` +- [x] 26. Corpus wave 1b — the validation parent + consumers specs (4 specs, refine-target minimum part 2) + What to do / Must NOT do: Author: `specs/validation/two-check-families.sdp.md` (`spec:validation.two-check-families` — rule, feature, defined; refines `spec:protocol.self-hosting`; carries `05` §1 the two check families + the layered enforcement §1 — MD-14's refine target; anchors: the validator registry `src/validate/validators.ts:32-44`) · `specs/consumers/projections-model.sdp.md` (`spec:consumers.projections-model` — model, feature, defined; refines `spec:protocol.self-hosting`; carries `06` §1–§2 projections taxonomy + the two surfaces — D6's refine target; anchors: `src/projections/`) · `specs/consumers/agent-surface.sdp.md` (`spec:consumers.agent-surface` — behavior, feature, defined; refines `spec:consumers.projections-model`; carries `06` §3 the agent surface + the measured context-efficiency claim's generalized form (the ~⅕-tokens figure's future home); anchors: `src/reader/` — `createReader`) · `specs/consumers/design-review.sdp.md` (`spec:consumers.design-review` — behavior, feature, defined; refines `spec:consumers.projections-model`; carries `06` §5 Design Review — D4's future carrying surface; anchors: `renderDesignReview` in `src/projections/`). Pack manifest: add all four IDs in the same change. Coverage ledger rows per spec. Must NOT: restate MD-2/MD-14/D4–D6 as law text (the fold carries rulings); claim `ready` without floor evidence; author the MCP surface (designed-in deferred — the content belongs to D6's fold spec, not here). + Parallelization: Wave 6 | Blocked by: 24 | Blocks: 27 | Can parallelize with: 25 + References: concept sections `docs/concept/05-validation-and-honesty.md` §1, `docs/concept/06-consumers-and-projections.md` §1–§3, §5; registry `src/validate/validators.ts:32-44`; reader `src/reader/` (find `createReader` via grep); projections `src/projections/`; corpus style (todo 25 references). + Acceptance criteria: four specs in the graph with precise anchors; coverage rows added; `npm run check` green. + QA scenarios: happy — generated graph greps (Evidence `.omo/evidence/self-hosting-phase-2/task-26-self-hosting-phase-2.graph.log`); failure — add an MCP-surface section, catch the scope violation against Must-NOT, remove (Evidence `.../task-26-self-hosting-phase-2.scope.log`). + Commit: Y | `feat(specs): corpus wave 1b — validation parent + consumers (refine-target minimum)` +- [x] 27. Fold-target map verification + pack framing shed + wave-1 DR prep + What to do / Must NOT do: Verify the fold can start: walk the pinned 19-row refine-target map (M8) and confirm every target spec either exists now (post-todos 25–26: all S5 targets + the pre-existing carrier/readiness-floor/envelope/prose-ownership targets) or has a recorded landing wave (MD-20's `spec:extraction.excludes` → S7); record the verdict table in plan 18 §4. Record the two born specs' keep-vs-repoint pre-verdicts (recommendation: keep both at `spec:protocol.self-hosting` — genuinely phase-level). Shed the pack's phase-1 framing: `specs/self-hosting.pack.sdp.ts` title `"Self-hosting phase 1"` → `"Self-hosting"` and framing → phase-agnostic wording (e.g. "The Protocol authors and validates its own delivery model.") — the pack ID `pack:self-hosting-v1` is stable and unchanged. Must NOT: change any pack membership here (todos 25–26 already grew it); re-point the born specs yet (the fold ledger records the verdict at todo 32; a relation edit happens only if the owner overrules the recommendation). + Parallelization: Wave 6 | Blocked by: 25, 26 | Blocks: 28 + References: the refine-target map (this plan M8); born specs `specs/decisions/plain-language-references.sdp.md:7`, `specs/decisions/concept-docs-dissolve.sdp.md:7`; pack manifest `specs/self-hosting.pack.sdp.ts:1-25`; owner §6.5 `plans/18-self-hosting-phase-2-brief.md:299-308`. + Acceptance criteria: the verdict table exists with 19/19 targets resolved to `exists` or a named wave; pack title/framing updated; `npm run check` green. + QA scenarios: happy — verdict table (Evidence `.omo/evidence/self-hosting-phase-2/task-27-self-hosting-phase-2.targets.md`); failure — drop MD-20's landing wave from the table, catch it against the map, fix (Evidence `.../task-27-self-hosting-phase-2.map.log`). + Commit: Y | `docs(plans): verify fold targets; shed the pack's phase-1 framing` +- [~] 28. Session 5 close — gate G5 + What to do / Must NOT do: Assemble the G5 evidence pack: the 10 new corpus specs with their anchors and coverage rows, the fold-target verdict table, and the generated Design Review view (the corpus's own projection — the owner reviews the wave through it). Present; on acceptance, stamp G5 (owner disposition + date + SHA) in plan 18 §10 and the gates script's plan-18 block; stamp docket rows. Must NOT: start the fold before the owner's G5 disposition. + Parallelization: Wave 6 | Blocked by: 27 | Blocks: 29 + References: gate mechanics (todo 12 references); acceptance 5 `plans/18-self-hosting-phase-2-brief.md:358-361`. + Acceptance criteria: G5 stamped; `node ./check-self-hosting-gates.mjs` exits 0; `npm run check` green. + QA scenarios: happy — post-stamp log (Evidence `.omo/evidence/self-hosting-phase-2/task-28-self-hosting-phase-2.g5.log`); failure — stamp with a coverage row missing, catch against acceptance 5, fix (Evidence `.../task-28-self-hosting-phase-2.coverage.log`). + Commit: Y | `docs(plans): session 5 close — gate G5 accepted (corpus wave 1)` +- [x] 29. Fold start — the namespace amendment + the fold ledger opens + What to do / Must NOT do: Amend `docs/concept/DECISIONS.md`'s ratified-name registry "Future spec id" column to `spec:decisions.*` (16 rows; the born form is ratified — two segments like every corpus spec, `specs/decisions/` subdir mirrors the domain layout; the three-segment form would collide with the dotted-lineage convention — owner §6.1). No re-ID of the two born specs, no reference churn (their IDs already use the born form). Open the fold ledger in plan 18 §4 (template from todo 1) with all rows seeded: 19 registry rows (16 durable + MD-8/MD-9/MD-13 folded/folds) + D1–D6 + 4 measured-evidence figures + 2 born-spec verdicts. Update the "Current executable decision-spec pointers" section header wording if it references the old namespace. Must NOT: author any decision spec in this todo (the amendment lands alone so the fold's diffs stay readable); delete any diary body yet; touch the post-flip pinned strings (the rule script's pins live in the MD-18 row's region — the registry-table edit must not alter the Decision-text region of the file; `node ./check-carrier-rule.mjs` must stay green). + Parallelization: Wave 7 | Blocked by: 28 | Blocks: 30, 31 + References: registry table `docs/concept/DECISIONS.md:29-49`; pointers section `:51-56`; owner §6.1 `plans/18-self-hosting-phase-2-brief.md:276-281`; rule-script pins (todo 22's renamed `check-carrier-rule.mjs`). + Acceptance criteria: the column shows `spec:decisions.*` uniformly; the fold ledger seeded; `node ./check-carrier-rule.mjs` + `npm run check` green. + QA scenarios: happy — grep confirms the deprecated reserved namespace is absent repo-wide (outside git internals); failure — leave one row at the deprecated prefix, catch with the same grep, fix (Evidence `.../task-29-self-hosting-phase-2.leftover.log`). + Commit: Y | `docs(decisions): amend the reserved decision-spec namespace to spec:decisions.*` +- [x] 30. Fold batch 1 — the carrier/extraction cluster (6 decision specs) + What to do / Must NOT do: Author six `decision`-kind specs under `specs/decisions/`, each distilling its diary body per the §4 rubric (the law + non-obvious rejected alternatives + non-obvious consequences; leanness 1–3 sentences per slot; current law only; plain language leads with the code parenthetical; zero procedural context — no dates, SHAs, session numbers, status stamps): `spec:decisions.one-validation-path` (MD-14; refines `spec:validation.two-check-families`) · `spec:decisions.sdp-ts-extension` (MD-15; refines `spec:carrier.markdown-authoring`; state the post-flip law — the extension law re-pointed to the `.sdp.md` sibling) · `spec:decisions.point-per-example` (MD-17; refines `spec:model.spec-sections`) · `spec:decisions.carrier-ruling` (MD-18; refines `spec:carrier.markdown-authoring`; states current post-flip law — the amendment history lives in git) · `spec:decisions.prose-ownership` (MD-19; refines `spec:carrier.prose-ownership-rule`) · `spec:decisions.envelope-grammar-posture` (MD-21; refines `spec:carrier.envelope-contract`). Altitude `feature`, readiness `defined` (the two born specs are the size/style template). Add `decidedBy` back-edges on the shaped specs (e.g. `spec:carrier.markdown-authoring` gains `decidedBy: [spec:decisions.carrier-ruling, spec:decisions.sdp-ts-extension]` — preserving any existing relations; the envelope relations grammar allows multi-value). Add all six IDs to the pack manifest in the same change. Delete the six distilled diary bodies from `DECISIONS.md` in the same change — the lean registry rows keep name · one-line gloss · spec pointer (the MD-18 row's gloss carries the post-flip carrier sentence verbatim so `check-carrier-rule.mjs` stays green). Fold-ledger rows filled (disposition: decision spec; target; back-edges; three-part-test verdict). Must NOT: transplant diary prose (distillation, never a diary transplant); include execution context; state readiness above `defined` without floor evidence; break the rule script's pins (run it). + Parallelization: Wave 7 | Blocked by: 29 | Blocks: 32 | Can parallelize with: 31 + References: diary bodies `docs/concept/DECISIONS.md` (MD-14/15/17/18/19/21 entries — read each before distilling); rubric `plans/18-self-hosting-phase-2-brief.md:196-238` (§4 + litmus tests); size templates `specs/decisions/plain-language-references.sdp.md:1-18`, `specs/decisions/concept-docs-dissolve.sdp.md:1-18`; decision section grammar `plans/17b-self-hosting-sessions-1-4.md:148`; refine-target map (this plan M8). + Acceptance criteria: six specs in the graph with correct `refines`/`decidedBy` edges; `DECISIONS.md` bodies removed, lean rows point at the specs; `node ./check-carrier-rule.mjs` + `npm run check` green; every spec passes the §4 rubric at review (no procedural sentence present — grep for date/SHA/session patterns in the new specs returns empty). + QA scenarios: happy — graph edges grepped + rubric sweep log (Evidence `.omo/evidence/self-hosting-phase-2/task-30-self-hosting-phase-2.fold1.log`); failure — leave a "landed at …" sentence in one spec, catch with the procedural-context grep, rewrite (Evidence `.../task-30-self-hosting-phase-2.procedural.log`). + Commit: Y | `feat(specs): the decision fold — carrier/extraction cluster (MD-14/15/17/18/19/21)` +- [x] 31. Fold batch 2 — the model/phase cluster (9 decision specs) + What to do / Must NOT do: Same discipline as todo 30 for: `spec:decisions.executable-meta-model` (MD-1; refines `spec:protocol.self-hosting` — genuinely phase-level) · `spec:decisions.adopt-the-nouns` (MD-2; refines `spec:protocol.self-hosting`) · `spec:decisions.one-primitive` (MD-4; refines `spec:model.core-model`) · `spec:decisions.protocol-naming` (MD-5; refines `spec:protocol.self-hosting`) · `spec:decisions.binding-not-liveness` (MD-7; refines `spec:model.anchors`) · `spec:decisions.content-only-sections` (MD-10; refines `spec:model.spec-sections`) · `spec:decisions.typing-law` (MD-11; refines `spec:model.spec-sections`) · `spec:decisions.kind-conditional-floor` (MD-12; refines `spec:validation.readiness-floor`) · `spec:decisions.carried-evidence` (MD-16; refines `spec:validation.readiness-floor` — the carried-evidence law is implemented in `src/validate/readiness-floor.ts:150-181`; the authoring-shape stand-in and `doc:`-deferral points ride as body content/consequences). `decidedBy` back-edges on shaped specs; pack manifest grows; diary bodies deleted with lean rows kept; fold-ledger rows filled. Must NOT: (todo 30's constraints); use the fallback parent for any decision with a ruled domain (only MD-1/MD-2/MD-5 are genuinely phase-level — the map is pinned; deviating requires an owner gate). + Parallelization: Wave 7 | Blocked by: 29 | Blocks: 32 | Can parallelize with: 30 + References: diary bodies `docs/concept/DECISIONS.md` (MD-1/2/4/5/7/10/11/12/16); rubric + fallback rule `plans/18-self-hosting-phase-2-brief.md:299-308`; templates (todo 30 references). + Acceptance criteria: nine specs in the graph; back-edges present; lean registry rows point correctly; `node ./check-carrier-rule.mjs` + `npm run check` green; rubric sweep clean. + QA scenarios: happy — graph greps + rubric sweep (Evidence `.omo/evidence/self-hosting-phase-2/task-31-self-hosting-phase-2.fold2.log`); failure — refine `spec:decisions.typing-law` at the fallback, catch against the pinned map, fix to `spec:model.spec-sections` (Evidence `.../task-31-self-hosting-phase-2.map.log`). + Commit: Y | `feat(specs): the decision fold — model/phase cluster (MD-1/2/4/5/7/10/11/12/16)` +- [x] 32. Fold batch 3 — D3/D5/D6 specs + the verification dispositions (D1/D2/D4, MD-8/9/13, born specs) + What to do / Must NOT do: Author three decision specs per the D-table's own KEEP note: `spec:decisions.pack-reified` (D3; refines `spec:model.pack-aggregate`) · `spec:decisions.agent-surface-scripts-graph` (D5; refines `spec:consumers.agent-surface`) · `spec:decisions.mcp-deferred` (D6; refines `spec:consumers.projections-model` — the MCP-deferred *no*: integration for user-facing apps, designed-in, deferred build). Then the verification dispositions, recorded in the fold ledger with named evidence surfaces (verify, never re-author): D1 → carried by `spec:model.core-model` (confirm the spec carries the readiness-vs-delivery-facts distinction); D2 → carried by `spec:model.spec-sections` (confirm typed-sections content); D4 → carried by `spec:consumers.design-review` (confirm the flagship-projection content); MD-8 → folded, evidence: the doc-comment on the `codeAnchor` builder in `src/model/anchors.ts` (confirm present); MD-9 → folds, evidence: `src/model/sections.ts` + the floor + `02` §3; MD-13 → folds, evidence: `05` §3's Representation note + the `readiness-floor.ts` header. Record the two born specs' keep verdicts (recommended: keep at `spec:protocol.self-hosting`). The D1–D6 table stays in the lean registry with its KEEP note (it retires when the artifacts citing `(Dn)` dissolve — recorded). Must NOT: re-author any folded/folds content; delete the D-table; change the born specs' IDs (a relation edit only if the owner overrules the keep recommendation). + Parallelization: Wave 7 | Blocked by: 30, 31 | Blocks: 33 + References: D-table + KEEP note `docs/concept/DECISIONS.md:533-544`; owner §6.6b `plans/18-self-hosting-phase-2-brief.md:312-315`; evidence surfaces `src/model/anchors.ts`, `src/model/sections.ts`, `src/validate/readiness-floor.ts`, `docs/concept/05-validation-and-honesty.md` §3. + Acceptance criteria: three specs in the graph; six verification dispositions recorded with named surfaces (each surface confirmed to carry the content — quote the line in evidence); born-spec verdicts recorded; `npm run check` green. + QA scenarios: happy — surface quotes per disposition (Evidence `.omo/evidence/self-hosting-phase-2/task-32-self-hosting-phase-2.verify.md`); failure — record MD-8 as verified without the doc-comment quote, catch against acceptance 4's "named evidence surfaces", fix (Evidence `.../task-32-self-hosting-phase-2.evidence.log`). + Commit: Y | `feat(specs): the decision fold — D3/D5/D6 + verification dispositions` +- [x] 33. Measured-evidence dispositions + records debt cluster + `DECISIONS.md` becomes the lean registry + What to do / Must NOT do: (a) Measured evidence: record per-figure dispositions in the fold ledger (figure → carrier spec → landing wave); move the two S5-carried figures now — the generalized claim text already lives in `06` §3/§10 (⅕ tokens) and `06` §2 (curated selection); the figures themselves move into `spec:consumers.agent-surface` / `spec:consumers.projections-model` as evidence lines in their bodies (measured evidence, honestly attributed as measured); delete those two rows from the measured-evidence section; the two S7-carried figures stay with their disposition recorded (`→ spec:extraction.regenerability at S7`). (b) Records cluster (owner §6.8): repair the stale provenance wording `approval provenance is git-native` at `docs/concept/05-validation-and-honesty.md:106` and `CONTEXT.md:182` (align with the glossary's rejection of the term — the line is pre-existing, outside phase-1's scope, deliberately adopted now); record the plan-16 §6 per-item REPAIRED/SUPERSEDED evidence dispositions into the docket (they currently exist only in ignored evidence); narrow the overclaimed comment in `check-carrier-truth.mjs` (the formatter-re-wrap tolerance claim, review-06 lines 287–288) to match its line-based Family-C matching. (c) Shrink `DECISIONS.md` to the lean registry: the ratified-name table (name · one-line gloss · spec pointer · folded/CONTEXT-only dispositions) + the pointers section + the surviving D-table + the shrunk measured-evidence section + intro framing; all distilled bodies are gone (git is the archive). Keep the post-flip carrier sentence verbatim in the MD-18 row's gloss (rule-script pin); if any pinned string must change, re-pin `check-carrier-rule.mjs` in the same change. Must NOT: drop any registry row; lose a figure without a recorded disposition; leave the stale provenance wording; break the rule/truth/gates scripts (run all three). + Parallelization: Wave 7 | Blocked by: 32 | Blocks: 34 + References: measured evidence `docs/concept/DECISIONS.md:551-556`; owner §6.6c `plans/18-self-hosting-phase-2-brief.md:315-317`; records items review-06 lines 287–298 + plan-17 rows `plans/17-self-hosting-v1.md:445-468`; the lean-registry target shape `plans/18-self-hosting-phase-2-brief.md:142-167`. + Acceptance criteria: the lean registry stands (bodies gone, rows complete, pointers resolve — `grep -c "spec:decisions\." docs/concept/DECISIONS.md` ≥ 19); two figures moved, two dispositioned; records items stamped in the docket; `node ./check-carrier-rule.mjs && node ./check-carrier-truth.mjs && node ./check-self-hosting-gates.mjs` + `npm run check` all green. + QA scenarios: happy — the three audit logs + lean-registry review (Evidence `.omo/evidence/self-hosting-phase-2/task-33-self-hosting-phase-2.lean.log`); failure — delete a figure row without its disposition, catch against acceptance 4, restore (Evidence `.../task-33-self-hosting-phase-2.figure.log`). + Commit: Y | `docs(decisions): shrink DECISIONS.md to the lean registry; land the records cluster` +- [~] 34. Session 6 close — gate G6 (fold partial-completion audit) + What to do / Must NOT do: Assemble the G6 evidence pack: the 18 landed decision specs (batches 1–3) with the §4 rubric sweep, the fold ledger (every row dispositioned; MD-20 + two evidence figures marked as landing at S7 by design), the lean `DECISIONS.md`, and the generated Design Review view. Audit acceptance 4's partial state: every registry row, D1–D6, and measured evidence carries exactly one recorded disposition (some dispositions are `lands at S7` — lawful per the targets-exist rule); the namespace is uniform and has no legacy reserved prefix. Present; on acceptance, stamp G6 (owner disposition + date + SHA). Must NOT: declare the fold complete (MD-20 + two figures land at S7); start wave-8 work before the owner's G6 disposition. + Parallelization: Wave 7 | Blocked by: 33 | Blocks: 35, 36, 37 + References: acceptance 4 `plans/18-self-hosting-phase-2-brief.md:353-357`; gate mechanics (todo 12 references). + Acceptance criteria: G6 stamped; the fold ledger shows 100% disposition coverage; `node ./check-self-hosting-gates.mjs` exits 0; `npm run check` green. + QA scenarios: happy — post-stamp log + disposition-coverage table (Evidence `.omo/evidence/self-hosting-phase-2/task-34-self-hosting-phase-2.g6.log`); failure — find one dangling registry pointer (grep for a `spec:decisions.*` ID with no matching file), fix before stamp (Evidence `.../task-34-self-hosting-phase-2.dangle.log`). + Commit: Y | `docs(plans): session 6 close — gate G6 accepted (the decision fold)` +- [x] 35. Corpus wave 2 — the validation deep-dive (6 rule specs, the bundling dispositions) + What to do / Must NOT do: Author under the modeling policy: `specs/validation/referential-integrity.sdp.md` (rule, story; refines `spec:validation.two-check-families`; the law: every edge and pack model reference resolves; anchors: the referential-integrity validator) · `specs/validation/claim-separation.sdp.md` (rule, story; node/edge types, claims, descriptors, endpoint contracts agree — the claim taxonomy never collapses; anchors: its validator) · `specs/validation/verification-linkage.sdp.md` (rule, feature; BUNDLES `conformance/verifies-linkage` + `conformance/oracle-linkage` — one law: declared verification resolves to a performing trace; the bundle disposition recorded in the coverage ledger; anchors: both validators) · `specs/validation/pack-coherence.sdp.md` (rule, story; pack membership non-duplicated, modelRefs target model specs; anchors: its validator) · `specs/validation/authored-honesty.sdp.md` (rule, feature; BUNDLES `honesty/authoring-shape` + `honesty/delivery-facts` — one law: machine truth is never authored; anchors: both validators) · `specs/validation/warn-level-signals.sdp.md` (rule, feature; BUNDLES `conformance/orphans` + `honesty/gaps` — one law: some signals warn and never fail; anchors: both validators). Readiness `defined` (or `ready` only where the floor genuinely clears with the anchored validator as evidence — state honestly per spec). Pack manifest grows in the same change. Coverage ledger rows: `05` §2 (the MVP graph validators) per spec, floor-clause evidence noted for the readiness-floor spec's siblings. Must NOT: author one spec per check mechanically (the pressure target is disposition-over-count — the three bundles are deliberate, recorded in the ledger); duplicate `spec:validation.duplicate-ids*` / `spec:validation.readiness-floor` content (they already carry two of the eleven checks); add decorative anchors. + Parallelization: Wave 8 | Blocked by: 34 | Blocks: 38 | Can parallelize with: 36, 37 + References: the 11 checks `src/validate/validators.ts:32-44` (read each validator's header before anchoring); concept `docs/concept/05-validation-and-honesty.md` §1–§3; the bundling law `plans/18-self-hosting-phase-2-brief.md:179-182`; existing specs `specs/validation/duplicate-ids.sdp.md`, `readiness-floor.sdp.md`. + Acceptance criteria: six specs in the graph; the 11-check coverage is complete (2 pre-existing + 6 new covering the remaining 9 via the recorded bundles — the coverage ledger shows all 11 check IDs mapped); `npm run check` green. + QA scenarios: happy — the 11-check map + graph greps (Evidence `.omo/evidence/self-hosting-phase-2/task-35-self-hosting-phase-2.checks.md`); failure — author a seventh unbundled spec for an already-bundled check, catch the filler against the modeling policy, remove (Evidence `.../task-35-self-hosting-phase-2.filler.log`). + Commit: Y | `feat(specs): corpus wave 2 — validation deep-dive with recorded bundling` +- [x] 36. Corpus wave 3 — the consumers remainder (2 specs + enrichments) + What to do / Must NOT do: Author `specs/consumers/reader.sdp.md` (behavior, feature, defined; refines `spec:consumers.agent-surface`; carries `06` §3's reader — the thin typed loader, entry adapters + impact; anchors: `createReader` and the impact/blast-radius surface in `src/reader/`) · `specs/consumers/edit-model.sdp.md` (behavior, feature, defined; refines `spec:consumers.projections-model`; carries `06` §4 the edit model — intent composition, not patching: edits flow intent → agent → git → conformance checks; anchors: none — design intent not yet implemented as a single entrypoint; state readiness honestly at `defined` with the floor respected). Enrich `spec:consumers.projections-model` in place with any `06` §6 delivery-process vocabulary content that is genuinely model fact (never the MD-2 ruling — already folded). Coverage ledger rows. Pack manifest grows. Must NOT: anchor the edit model decoratively (no single realizing entrypoint exists — honesty over coverage); restate D5/D6 (folded); author the MCP surface. + Parallelization: Wave 8 | Blocked by: 34 | Blocks: 38 | Can parallelize with: 35, 37 + References: concept `docs/concept/06-consumers-and-projections.md` §3–§4, §6; reader sources `src/reader/`; the no-decorative-anchors policy (Execution strategy §policy). + Acceptance criteria: two specs in the graph with honest readiness/anchors; the projections-model enrichment diff minimal and factual; `npm run check` green. + QA scenarios: happy — graph greps (Evidence `.omo/evidence/self-hosting-phase-2/task-36-self-hosting-phase-2.graph.log`); failure — add an anchor to the edit-model spec, catch the decorative-anchor violation at review, remove (Evidence `.../task-36-self-hosting-phase-2.anchor.log`). + Commit: Y | `feat(specs): corpus wave 3 — consumers remainder` +- [x] 37. Corpus wave 4 — extraction & the one graph (5 specs + seed enrichments) + What to do / Must NOT do: Author: `specs/extraction/excludes.sdp.md` (rule, feature, defined; refines `spec:extraction.derive-graph`; carries the exclusion surface — MD-20's refine target: strict consumer-exclusion, exact root-relative POSIX path prefixes; anchors: the exclude normalization + matcher in `src/extract/index.ts` / `ExtractOptions`) · `specs/extraction/claim-taxonomy.sdp.md` (model, feature, defined; refines `spec:extraction.derive-graph`; carries `03` §3 — declared/anchored/inferred never collapsed; anchors: `src/graph/schema.ts` claims, `graphClaims`) · `specs/extraction/regenerability.sdp.md` (rule, feature, defined; refines `spec:extraction.determinism`; carries `03` §4 regenerability + the no-second-store rule — also the future home of the two remaining measured-evidence figures; anchors: the `--check-clean` producer-repeat seam `src/cli/sdp.ts`) · `specs/extraction/schema-versioning.sdp.md` (rule, story, defined; refines `spec:extraction.derive-graph`; carries `03` §6 minimal schema versioning; anchors: `schemaVersion` in `src/graph/schema.ts`) · `specs/extraction/executable-contracts.sdp.md` (behavior, feature, defined; refines `spec:extraction.build-pipeline`; carries the A2 contract-derivation machinery (plan 13) — step contracts + space contracts; anchors: `generateContracts`). Enrich the phase-1 seeds in place where `03` content genuinely deepens them (`spec:extraction.derive-graph` ← `03` §1 edge-contract facts; `spec:extraction.determinism` ← `03` §2; git-as-event-log §5 → carried as narrative on `derive-graph` — disposition recorded, no new spec). Coverage ledger rows; pack manifest grows. Must NOT: author a spec for exposition-only content (record the disposition instead); restate MD-20 (it folds at todo 38); raise readiness above floor. + Parallelization: Wave 8 | Blocked by: 34 | Blocks: 38 | Can parallelize with: 35, 36 + References: concept `docs/concept/03-the-one-graph.md` §1–§6 (heading map verified at planning); exclusion plumbing `src/extract/index.ts` + `ExtractOptions`; contracts `src/codegen/` (`generateContracts`); schema `src/graph/schema.ts`; MD-20 diary body `docs/concept/DECISIONS.md:48` + its registry row. + Acceptance criteria: five specs in the graph with precise anchors; seed enrichments landed; the §5 disposition recorded; `npm run check` green. + QA scenarios: happy — graph greps + coverage rows (Evidence `.omo/evidence/self-hosting-phase-2/task-37-self-hosting-phase-2.graph.log`); failure — author a git-event-log spec, catch the exposition-only violation against the modeling policy, replace with the recorded disposition (Evidence `.../task-37-self-hosting-phase-2.exposition.log`). + Commit: Y | `feat(specs): corpus wave 4 — extraction and the one graph` +- [x] 38. Fold completion — MD-20 + the last measured-evidence moves + watch-item re-check + What to do / Must NOT do: (a) Author `specs/decisions/exclusion-contract.sdp.md` (MD-20; decision, feature, defined; refines `spec:extraction.excludes` — the target now exists; distill the diary body per the §4 rubric; `decidedBy` back-edge on `spec:extraction.excludes`; pack manifest grows; delete MD-20's diary body, lean row points at the spec). (b) Move the two remaining measured-evidence figures into `spec:extraction.regenerability` (the `< ~50 specs` full-rebuild comfort and the `~10k+ nodes` graph-DB deferral — honestly attributed as measured); delete those rows; the measured-evidence section is now empty — retire it from `DECISIONS.md` (the section header + a one-line retired note, or remove per the lean-registry style; record the retirement in the fold ledger). (c) Re-check all five watch items' states after waves 2–4 (any newly fired item gets its ruling recorded; table sugar's verdict from todo 16 still stands unless a wave forced it). (d) Fold-ledger final sweep: every row's disposition now terminal (no `lands at S7` remains). Must NOT: retire the measured-evidence section with a figure unmoved; forget the `decidedBy` back-edge; break the audit scripts (run all three). + Parallelization: Wave 8 | Blocked by: 35, 36, 37 | Blocks: 39 + References: MD-20 diary body `docs/concept/DECISIONS.md:48`; measured evidence `:551-556`; owner §6.6c `plans/18-self-hosting-phase-2-brief.md:315-317`; watch items (todo 16 references). + Acceptance criteria: `spec:decisions.exclusion-contract` in the graph with both edges; measured-evidence section retired empty; watch-item states current; fold ledger fully terminal; `node ./check-carrier-rule.mjs && node ./check-carrier-truth.mjs && node ./check-self-hosting-gates.mjs` + `npm run check` green. + QA scenarios: happy — the terminal fold ledger + audit logs (Evidence `.omo/evidence/self-hosting-phase-2/task-38-self-hosting-phase-2.fold-done.log`); failure — leave one figure row, catch against acceptance 4's exactly-one-disposition rule, fix (Evidence `.../task-38-self-hosting-phase-2.figure.log`). + Commit: Y | `feat(specs): fold completion — the exclusion-contract decision + evidence moves` +- [x] 39. Session 7 close — gate G7 (coverage-ledger completeness audit) + What to do / Must NOT do: Assemble the G7 evidence pack: waves 2–4 specs with anchors and coverage rows, the completed fold ledger, the coverage-ledger completeness audit (every corpus spec maps its concept-doc contract — no orphan specs, no double-claimed sections without a recorded disposition), acceptance 5's evidence (floor-honest readiness sweep: for every corpus spec, stated rung vs floor evidence — a table), and the generated Design Review view. Present; on acceptance, stamp G7 (owner disposition + date + SHA). Must NOT: start phase-close before the owner's G7 disposition; leave any coverage row blank. + Parallelization: Wave 8 | Blocked by: 38 | Blocks: 40 + References: acceptance 5 `plans/18-self-hosting-phase-2-brief.md:358-361`; floor table `src/validate/readiness-floor.ts`; gate mechanics (todo 12 references). + Acceptance criteria: G7 stamped; the coverage audit + floor-honesty table attached; `node ./check-self-hosting-gates.mjs` exits 0; `npm run check` green. + QA scenarios: happy — post-stamp log + audits (Evidence `.omo/evidence/self-hosting-phase-2/task-39-self-hosting-phase-2.g7.log`); failure — find one spec above floor, drop its rung honestly, re-stamp (Evidence `.../task-39-self-hosting-phase-2.floor.log`). + Commit: Y | `docs(plans): session 7 close — gate G7 accepted (corpus waves complete)` +- [x] 40. Docket reconciliation + whole-phase anti-drift audit + What to do / Must NOT do: (a) Reconcile plan 18 §8's docket: every row carried from plan 17 §6 (`plans/17-self-hosting-v1.md:445-468`), every review-06 cluster row, and every phase-2-added row gets a terminal disposition — `done ` / `deferred ` / `dropped ` — nothing silently dropped; the no-reparse spy seam and temporal token assembly stay `deferred` with their reasons carried forward verbatim; plan-16's evidence-disposition rows recorded (todo 33) stamped. (b) The anti-drift audit: intended-truth vs realization sweep — `npm run check` green; every corpus spec floor-honest (re-run the todo-39 table); no filler specs (every spec passes the "real content forced it" test at review); the five watch items terminal for the phase; acceptance 1–8 evidence assembled into one file with per-criterion links to the owning todos' logs. Must NOT: disposition a row `done` without its evidence link; let the audit pass with any readiness-above-floor. + Parallelization: Wave 9 | Blocked by: 39 | Blocks: 41, 42 + References: plan-17 deferred rows `plans/17-self-hosting-v1.md:445-468`; review-06 clusters (todo 10/15/18/33 references); acceptance 1–8 `plans/18-self-hosting-phase-2-brief.md:337-371`. + Acceptance criteria: the docket shows 100% terminal dispositions with evidence links; the acceptance-evidence file covers all eight criteria; `npm run check` green. + QA scenarios: happy — the reconciled docket + evidence assembly (Evidence `.omo/evidence/self-hosting-phase-2/task-40-self-hosting-phase-2.docket.md` and `.../task-40-self-hosting-phase-2.acceptance.md`); failure — find one row stamped done without evidence, re-open it, complete or defer honestly (Evidence `.../task-40-self-hosting-phase-2.reopen.log`). + Commit: Y | `docs(plans): reconcile the phase-2 docket and assemble acceptance evidence` +- [x] 41. The adversarial code review + verified remediation (the phase-1 F-wave pattern) + What to do / Must NOT do: Commission the whole-phase adversarial review of the phase's diff (the phase-1 precedent: `reviews/06-self-hosting-phase-1-code-review.md` — an external/adversarial pass over every commit since the branch point, hunting correctness, honesty, drift, and misleading-success risks): archive it at `reviews/07-self-hosting-phase-2-code-review.md`; then a verified remediation pass — every finding fixed or dispositioned with owner-visible reasoning, each remediation verified by re-running the failing probe (never a self-reported fix). The review must cover at minimum: the emitter's fidelity (any construct it silently drops), the migration's atomicity, the flip's docs-agree completeness, the fold's rubric conformance, the corpus's floor honesty, and the new public surface's contract stability. Must NOT: let the review pass on worker self-report (each finding's remediation carries its re-probe log); close with any BLOCKER unfixed (defer only with an owner-visible disposition). + Parallelization: Wave 9 | Blocked by: 40 | Blocks: 43 | Can parallelize with: 42 + References: the phase-1 review + its remediation pattern `reviews/06-self-hosting-phase-1-code-review.md` (structure, severity taxonomy, addendum format); the phase diff (`git log main..HEAD` on the effort branch). + Acceptance criteria: `reviews/07-self-hosting-phase-2-code-review.md` exists with severity-classified findings; every finding has a remediation + re-probe log or an owner-visible disposition; `npm run check` green after remediation. + QA scenarios: happy — the review archive + remediation logs (Evidence `.omo/evidence/self-hosting-phase-2/task-41-self-hosting-phase-2.review.md`); failure — pick one remediated finding, re-run its original failing probe to confirm the fix holds (Evidence `.../task-41-self-hosting-phase-2.reprobe.log`). + Commit: Y | `docs(reviews): archive the phase-2 adversarial review with verified remediation` +- [x] 42. Final installed-package + clean-clone proofs + What to do / Must NOT do: Re-run the full installed-package proof (todo 11's script — verb + barrel exports) against the phase-final tree, and the clean-clone proof (todo 23's procedure) at the phase-final SHA: fresh clone → `npm ci` → full `npm run check` → determinism legs (`--check-clean` in both generate/check legs already in the chain). Also re-run the three audit scripts at the final SHA. Must NOT: reuse earlier logs (stale_state discipline — the proofs run at the final SHA). + Parallelization: Wave 9 | Blocked by: 40 | Blocks: 43 | Can parallelize with: 41 + References: todo 11/23 procedures; `package.json:43-49`. + Acceptance criteria: all proofs exit 0 at the final SHA with fresh logs. + QA scenarios: happy — the fresh logs (Evidence `.omo/evidence/self-hosting-phase-2/task-42-self-hosting-phase-2.final-proofs.log`); failure — run one proof against the dev tree instead of the clone, catch the stale_state violation, re-run correctly (Evidence `.../task-42-self-hosting-phase-2.stale.log`). + Commit: N (evidence only) +- [x] 43. Whole-phase close — gate G8 + status flip + the done-record + What to do / Must NOT do: Present the G8 evidence pack (docket reconciliation, anti-drift audit, acceptance 1–8 assembly, the archived review + remediation, the final proofs) to the owner. On acceptance: stamp G8 (owner disposition + date + SHA) in plan 18 §10 and the gates script; flip plan 18's status header to `✅ EXECUTED — phase-2 implementation complete; final audit passed`; write the done-record in plan 18 §7 — the delta catalog as executed, every session's SHA, the watch-item terminal states, the rulings admitted to the lean registry during the phase (three-part-test verdicts), and the remaining deferred tail carried forward (spy seam, temporal token assembly, anything newly deferred — each with its reason); update `AGENTS.md`'s "what now" to point at plan 18 as the latest ✅ EXECUTED ground (and name the phase-3 remainder per the brief: the systematic tests-to-executable-specs rewrite + per-doc concept deletion). Must NOT: declare completion with any gate un-stamped or any acceptance criterion's evidence missing; start phase-3 work. + Parallelization: Wave 9 | Blocked by: 41, 42 | Blocks: F1–F4 + References: the phase-3 remainder `plans/18-self-hosting-phase-2-brief.md:15-18,373-379`; status-header precedent `plans/17-self-hosting-v1.md:1-4`; gate mechanics (todo 12 references). + Acceptance criteria: G8 stamped; status header flipped; `AGENTS.md` updated; `node ./check-self-hosting-gates.mjs` + `npm run check` green at the final SHA. + QA scenarios: happy — final gate log (Evidence `.omo/evidence/self-hosting-phase-2/task-43-self-hosting-phase-2.g8.log`); failure — flip the status header before the owner's stamp, catch the ordering violation, revert (Evidence `.../task-43-self-hosting-phase-2.order.log`). + Commit: Y | `docs(plans): whole-phase close — gate G8 accepted; plan 18 executed` + +## Final verification wave +> Runs in parallel after ALL todos. ALL must APPROVE. Surface results and wait for the user's explicit okay before declaring complete. Every audit is agent-executed with evidence pinned under `.omo/evidence/self-hosting-phase-2/final/` (phase-1 layout) — none relies on worker self-report. +- [x] F1. Plan compliance audit — independent of worker self-report: walk all 43 todos + gates G1–G8 + the docket ledger + acceptance criteria 1–8 against the executed evidence. For each todo: its commit exists on `feature/protocol-self-application-phase-2` (`git log --oneline`), its acceptance command re-passes at the final SHA, its evidence artifacts exist under `.omo/evidence/self-hosting-phase-2/`. For each gate: the plan-18 §10 row carries owner disposition + date + SHA and the gates script passes. For each acceptance criterion: the assembled evidence (todo 40) links resolve. Any todo whose evidence is missing, whose acceptance re-fails, or whose commit message doesn't match its scope = CHANGES REQUESTED. Evidence `.omo/evidence/self-hosting-phase-2/final/F1-plan-compliance.md`. +- [x] F2. Code quality review — the engineering surface on a fresh clone: public API review (`emitMarkdownSpec`/`importTypeScriptSpec`/`importFindingIds` signatures, no fs in the library seam, no accidental export), refusal totality (both reifiers + the import path convert every throw into a finding — probe with malformed inputs), path/exclude safety (write-beside never escapes the source directory; exclude normalization post-debt-fix), packaging (the packed tarball's `dist/index.d.ts` surface, bin wiring, no repo-internal imports), plus a lint/typecheck/coverage pass of the full gate on the clone. Evidence `.omo/evidence/self-hosting-phase-2/final/F2-code-quality.md`. +- [x] F3. Real manual QA — executed as **live end-to-end QA, agent-executed (zero human intervention)**: on a clean clone run the full `npm run check`; run the packed-tarball `sdp import` smoke (todo 11 script at the final SHA); re-run the marquee negative probes (the half-flip docs-agree failure from todo 22 if still relevant post-flip, the drift-alarm from todo 9, the corrupted-twin round-trip from todo 8); spot-check the generated Design Review (`npm run generate:self-hosting`) for the new corpus rendering (decision specs, the enriched `spec:carrier.sdp-import`, the migrated example view). Evidence `.omo/evidence/self-hosting-phase-2/final/F3-live-qa.md`. +- [x] F4. Scope fidelity — intended-truth audit of the phase diff (`git diff main...HEAD`): every Must-NOT verified — no dual-authored product spec ID at any blessed commit (`git log --name-status` sweep for same-commit `.sdp.ts`+`.sdp.md` pairs of one ID outside todo 13's atomic migration), no second validation path (the import path never calls `validateGraph`/`extract` on its output), no TS DSL retirement (`reifyTypeScriptCarrier` exported, fixtures intact), no concept-doc deletion, no content-quality validator, no Markdown pack syntax, no gen-1 `.feature` adapter, no phase-3 bleed (no systematic test rewrite); and the CONTEXT/DECISIONS lean-registry state matches the fold ledger exactly. Evidence `.omo/evidence/self-hosting-phase-2/final/F4-scope-fidelity.md`. + +## Commit strategy + +Conventional commits on `feature/protocol-self-application-phase-2`, one commit per todo unless the todo says otherwise; `npm run check` green at every blessed commit. Two atomicity laws: **the checkout migration (todo 13) is a single atomic commit** — all 11 emits + all 11 TS deletes together, so no blessed commit ever holds both surfaces of an ID; **the flip (todo 22) is a single commit** — records + script re-pins + anti-misleading + `04` §1 together, so docs-agree checks never see a mixed state. Gate-stamp edits (repo plan ledger row + gates-script SHA) ride the session-close todo. Push only when the owner asks. Never `git stash`. + +## Success criteria + +The brief's acceptance criteria 1–8 (`plans/18-self-hosting-phase-2-brief.md` §7), each made checkable by the todo that owns it: (1) the flip is total and recorded — no TS-canonical product spec ID under blessed roots, operative surfaces moved in step, scripts retired/re-pointed, no active interim-rule claim (todos 13, 22–24); (2) round-trip fidelity proven under the pre-declared delta catalog, `npm run check` green on the full clean-clone sequence, installed-package proof for the new public surface, the authoring-surface bar judged at the migration's Design Review (todos 8, 11, 13, 17, 42); (3) the hardening non-claim resolved on the parser specs (todos 18–21); (4) the fold complete by disposition, not by count — every registry row, D1–D6, and measured evidence carries exactly one recorded disposition, `DECISIONS.md` is the lean registry, the namespace uniform, no reference dangles (todos 29–34, 38); (5) all four corpus waves land honest — floor-cleared readiness, precise anchors, coverage-ledger rows, no filler kinds (todos 25–28, 35–39); (6) every fired watch item ruled, every unfired one survives named — the five named items' states recorded in the docket/done-record and re-verified at the close audit (todos 16, 38, 40); (7) the docket ledger continues — plan-17 deferred rows carried or dispositioned, adopted clusters landed, owner-gated Design Review per session plus the whole-phase gate (todos 1, 12, 17, 21, 24, 28, 34, 39, 40, 43); (8) `sdp import` documented — the ratified-glossary amendment landed at todo 7 (verified and machine-pinned at todo 22), the CLI contract (arguments, output mode, finding IDs, exit codes, refusal-never-throw, help text) pinned by tests (todo 7), the installed-package smoke covers the verb and the barrel (todos 11, 42) — each owning todo credited only for work it does. diff --git a/plans/19-review-08-remediation.md b/plans/19-review-08-remediation.md new file mode 100644 index 0000000..9512245 --- /dev/null +++ b/plans/19-review-08-remediation.md @@ -0,0 +1,161 @@ +# Plan 19 — Review-08 remediation + +> **Status:** ✅ EXECUTED — review-08 remediation complete; final architecture review blockers, +> repeated full gate, and live product probes all passed. + +## Goal + +Resolve every valid major and minor finding from review 08, plus the adjacent note-level defects +that close the same contracts. Preserve the carrier, one-graph, conformance-and-honesty, and +never-delete-source laws. Record observations that have no governing defect instead of silently +inventing policy. + +## Validated disposition + +| Item | Disposition | +|---|---| +| R-1 | Fix: import rollback uses the shared never-throw removal path, preserves the publish error, and names every survivor. | +| R-2 | Fix: recursive import discovery shares the extractor's built-in directory exclusions and never follows discovered directory symlinks. A new user exclusion flag is not required to close the reproduced defect. | +| R-3 | Fix: lawful-but-unrepresentable TS shapes refuse with the first divergent authored path; title-less Specs name the required H1 boundary instead of emitting a fabricated title. | +| R-4 | Fix: package imports are trusted by specifier; relative imports are trusted only when they resolve to this runtime package's canonical builder modules. No public trust override. | +| R-5 | Fix: escaping follows Markdown context. Prose, headings, links, and tables escape; fenced JSON and inline code preserve literal data with context-specific encoding. | +| R-6 | Fix: every delivery-fact spelling is reserved in Markdown frontmatter and parity evidence covers them. | +| R-7 | Fix: replace both `localeCompare` uses with code-unit comparison. | +| R-8 | Fix: `AGENTS.md` describes the lean decision registry and routes rationale to git, plans, and carrying Specs. | +| R-9 | Fix: import computes and reports every source result before returning; any failure publishes nothing. | +| R-10 | Fix: canonical physical source identity deduplicates aliases before both dry-run and publication. | +| R-11 | Fix: a mixed Spec+Pack carrier refuses publication and names the required source split; import never creates a guaranteed duplicate-carrier state. | +| R-12 | Fix: an explicitly named non-carrier file is an `import/invalid-source-path` error even when other operands are valid. | +| R-13 | Fix: Pack findings are computed before the multi-Spec refusal and both are returned. | +| R-14 | Fix: the public typed emission error is exported beside the public function that throws it. | +| R-15 | Fix: the authored claim says finding-class parity and records severity/outcome asymmetry. | +| R-16 | Fix: a frontmatter finding flood retains the frontmatter finding id. | +| R-17 | Fix: directive findings point at the directive line. | +| R-18 | Fix: `CONTEXT.md` no longer sends rationale readers to the lean registry. | +| R-19 | Fix: D3, D5, and D6 point to their carrying decision Specs. | +| R-20 | Fix: the example-space comment describes context-aware escaping, not verbatim rendering. | +| R-21 | Fix: import findings use the shared `error`/`warning` severity vocabulary. | +| R-22 | Fix with scoped regression coverage for the R-3 refusal family, public typed error, omitted kinds/sections, and multi-target relations. | +| R-23 | Fix the contract-bearing tail: atomic no-clobber publication, `--` operand termination, path-free messages, no fabricated lines, and no dead branch. SIGKILL residue is not claimed recoverable. | +| R-24 | Record only: control characters have the same authored latitude as TS strings; no carrier law forbids them. | +| R-25 | Fix: own-property checks prevent inherited names from producing false duplicate diagnostics. | +| R-26 | Fix with R-5: fenced JSON sorts and emits raw keys, so visible order and copied data agree. | +| R-27 | Partial fix with explicit exception: internal builder imports become relative. The two stable-id anchor casts remain the bootstrap seam because same-module local builder calls are intentionally not recognized as imported Protocol bindings. | +| R-28 | Record only: test granularity is real maintenance debt, but the systematic executable-spec rewrite is the named next program and this remediation does not churn the 2,282-line corpus test. | +| R-29 | Fix: the registry explains that retired/reserved decision numbers are never reused. | +| R-30 | Record only: root and example corpora are separate extraction roots and no current namespace collision exists. | +| R-31 | Already resolved by review 08; re-run the temporal guard only. | + +## Architecture rulings + +### Anchor-builder trust + +Raw relative spelling is not authority. The extraction boundary constructs an internal binding +scope from the runtime package's physical modules. `@libar-dev/software-delivery-protocol` always +qualifies. A relative import qualifies only when importer-relative resolution, including the +TypeScript `.js`-to-`.ts` convention, canonicalizes to this package's `ids` or +`model/code-anchor` module. Consumer-local lookalikes therefore mint no anchors, while deeply +nested Protocol source imports remain eligible. This is drift repair to the documented +import-binding contract, not a new public option. + +### Import transaction + +The CLI workflow separates scan, plan, prepare, and publish. Scanning is bounded and canonical; +planning reads and reifies every source and checks every target before writing. A failed plan +publishes nothing. Preparation writes exclusive temporary siblings. Publication uses atomic +hard-link creation so an existing target cannot be replaced; temporary names are then removed. +Rollback attempts every temporary and every target created by this invocation through the shared +never-throw removal helper, reports the original failure first, and never includes a TS source. +Dry-run consumes the same complete plan as publication. + +### Markdown rendering contexts + +Markdown text positions use field escaping. Fenced JSON canonicalizes object key order but leaves +keys and string values raw before `JSON.stringify`. Inline code uses a delimiter longer than any +backtick run in the value. No global escape or global exemption is lawful. + +## Execution order + +1. Add RED regressions for anchor trust; extract protocol-binding recognition to a small internal module and fix R-4. +2. Replace Protocol package self-imports and document the necessary stable-id bootstrap casts (R-27). +3. Split the import CLI responsibility out of `src/cli/sdp.ts` without behavior change. +4. Add RED regressions for R-1/R-2/R-7/R-9/R-10/R-12/R-23; implement bounded canonical planning, fail collection, atomic publication, and never-throw rollback. +5. Fix import finding currency and mixed/multi carrier behavior (R-11/R-13/R-21). +6. Split emitter helpers before adding R-3 diagnostics; export the typed error and add the scoped round-trip coverage (R-3/R-14/R-22). +7. Fix reserved frontmatter vocabulary and parity semantics (R-6/R-15). +8. Fix frontmatter/body diagnostic accuracy (R-16/R-17/R-25). +9. Split Design Review section rendering without behavior change, then implement context-aware literal rendering (R-5/R-20/R-26). +10. Repair operative navigation and decision-registry drift (R-8/R-18/R-19/R-29), update carrying Specs, and regenerate self-hosting projections once. +11. Run diagnostics, focused suites, strict TypeScript audits, `npm run check`, and live CLI/library/view probes. Mark this plan executed only after all pass. + +## Verification contract + +- Focused tests: import CLI, pure import/emitter, round trip, extraction/parity, Markdown reifier, + Design Review, barrel/package smoke, and self-hosting graph. +- Static checks: diagnostics on every changed TypeScript file, lint, format, typecheck, example + typecheck, and the no-excuse audit. +- Full gate: `npm run check`. +- Manual surfaces: real built `sdp import` roots for exclusions, symlink aliases, multiple refusals, + explicit non-carrier operands, `--` operands, mixed carriers, and target collision; built library + import for the typed emission error; real `sdp view` output for fenced JSON and inline code; + consumer extraction for local builder lookalikes. + +## Done record + +- Every accepted R-1 through R-23, R-25 through R-27, and R-29 remediation landed. R-24, R-28, + and R-30 remain recorded observations without an invented policy; R-31 remained resolved. +- The import boundary now scans bounded canonical sources, reports all planning failures before + writing, publishes with atomic no-clobber links, rolls back through never-throw recovery, and + never deletes a TypeScript source. +- Builder trust now follows package import or physical Protocol module identity. Consumer-local + lookalikes produce no anchors; source, installed ESM, and CommonJS package surfaces pass. +- Design Review now encodes prose, fenced JSON, and inline code according to their Markdown syntax + contexts. Import fidelity reports the first divergent authored path, and Markdown refusal + diagnostics retain the correct finding class and location. +- Final architecture review found two edge-path blockers after the first green gate: an exclusive + temp-create failure could remove an unowned path, and source canonicalization could throw past + the CLI boundary. Regressions now prove rollback removes only created artifacts and + canonicalization failures collect while healthy siblings continue planning. +- `npm run check` passed: lint and format, package build, both typechecks, 461 repository tests, + the isolated 54-test CLI suite, deterministic self-hosting and example checks, and preflight. + The gate was run with the remediation's new files staged: preflight classifies untracked non-ignored files as runtime garbage by design, so `npm run check` requires a staged-or-committed tree. + The self-hosting graph remains 58 Specs, 1 Pack, 36 anchors, 95 nodes, and 180 edges with zero + findings. +- The strict TypeScript no-excuse audit passed across every changed and added TypeScript file. LSP + diagnostics report zero errors across the changed CLI module; broader directory requests were + intermittently unavailable, while the repository compiler, linter, declaration build, and + package smoke test supplied the full static proof. +- Live QA drove the built CLI through `--help`, dry-run with no target write, successful publication + with source preservation, existing-target refusal, explicit invalid-operand refusal, and the + public ESM barrel exports. + +### Closing wave — after the done record above + +Four repairs landed after that gate run, three of them fallout from the anchor-trust ruling and the +`setOwn` cluster reaching surfaces the remediation waves had not swept: + +- **Serialization keeps author-controlled keys.** The `setOwn` law stopped at reification; an + authored `__proto__` key survived extraction and then vanished in the canonical section + serializer, which rebuilt sections with plain assignment. The serializer now uses `setOwn` too, + so the carrier-to-graph path is own-property throughout, with a regression pinning it. +- **The agent workspace leaves the published tree.** `.gitignore` excludes `.omo/`, yet an + arbitrary slice of it had become tracked: the whole phase-2 workspace, but only five of the + remediation session's ~30 evidence files and one of its four notepads. That partial snapshot is + what forced a temporal-guard exemption for an ignored directory. The slice is untracked (the + files stay on disk), and the exemption is deleted with it — enumeration already honours + `.gitignore`, so the guard needed no carve-out once the tracking was honest. The durable + ledgers were archived into this directory for exactly this reason; the `.omo/…` evidence links + inside the archived phase plans stay session-local pointers, never published artifacts. +- **Preflight regenerates at trusted package identity.** Builder trust is physical identity to the + *running* package's modules, so a clean-room copy driven by the repository's own binary lawfully + loses every relative-import anchor. The first repair dropped the clean room and re-ran in place — + which, sitting downstream of `generate:*` and both `--check-clean` legs in the same chain, proved + little beyond two consecutive runs agreeing. +- **The clean room is restored, correctly this time.** The scratch root now carries `dist/` and + `package.json` beside the sources and executes *its own* `sdp`, so `nearestPackageRoot` lands + inside the copy and relative builder trust holds there. Preflight compares the committed tree + against a regeneration performed entirely outside this checkout: 58 Specs, 1 Pack, 36 anchors, + 95 nodes, 180 edges, byte-identical. Relocation-independence is proven rather than assumed, and + the trust ruling is now something the gate exercises instead of something it had to concede to. +- Gate at close: `npm run check` green end to end — 478 repository tests plus the isolated 54-test + CLI suite, both `--check-clean` legs, and the restored preflight clean room. diff --git a/preflight.mjs b/preflight.mjs index 7ad3355..d9b1b8e 100644 --- a/preflight.mjs +++ b/preflight.mjs @@ -13,7 +13,12 @@ import { fileURLToPath } from "node:url"; const repoRoot = fileURLToPath(new URL(".", import.meta.url)); const scratchRoot = join(repoRoot, ".tmp-scratch"); -const sdpPath = join(repoRoot, "dist", "cli", "sdp.js"); + +// The relocated runtime: builder trust is physical identity to the *running* package's modules, so +// the clean-room must carry its own `dist/` and `package.json` and execute those. Running the +// repo's binary against a copied tree would lawfully lose every relative-import anchor and make the +// comparison meaningless; running the copy's binary proves the regeneration is relocation-independent. +const runtimePaths = ["dist", "package.json"]; // The check pipeline owns precisely these outputs. Root dist/ is package assembly rather than // generated truth; broader ignored runtime garbage remains a manual inspection responsibility. @@ -22,7 +27,16 @@ const generationTargets = [ name: "self-hosting", generatedPath: "generated", sourcePaths: ["specs", "src", "test"], - command: ["view", "--exclude", "explorations", "--exclude", "examples"], + command: [ + "view", + ".", + "--exclude", + "explorations", + "--exclude", + "examples", + "--exclude", + "test/fixtures/import/parity", + ], }, { name: "checkout-v1", @@ -73,9 +87,7 @@ function readTree(root) { return files; } -function compareGeneratedTree(target, expectedRoot) { - const actual = readTree(join(repoRoot, target.generatedPath)); - const expected = readTree(expectedRoot); +function compareGeneratedTree(target, actual, expected) { const paths = new Set([...actual.keys(), ...expected.keys()]); return [...paths] @@ -89,12 +101,19 @@ function regenerateExpectedTree(target) { const temporaryRoot = mkdtempSync(join(scratchRoot, "preflight-")); try { - for (const sourcePath of target.sourcePaths) { + for (const sourcePath of [...runtimePaths, ...target.sourcePaths]) { cpSync(join(repoRoot, sourcePath), join(temporaryRoot, sourcePath), { recursive: true }); } - run(process.execPath, [sdpPath, ...target.command], { cwd: temporaryRoot }); - return compareGeneratedTree(target, join(temporaryRoot, target.generatedPath)); + run(process.execPath, [join(temporaryRoot, "dist", "cli", "sdp.js"), ...target.command], { + cwd: temporaryRoot, + }); + + return compareGeneratedTree( + target, + readTree(join(repoRoot, target.generatedPath)), + readTree(join(temporaryRoot, target.generatedPath)), + ); } finally { rmSync(temporaryRoot, { recursive: true, force: true }); } diff --git a/reviews/06-self-hosting-phase-1-code-review.md b/reviews/06-self-hosting-phase-1-code-review.md index 6bea750..a8a7d52 100644 --- a/reviews/06-self-hosting-phase-1-code-review.md +++ b/reviews/06-self-hosting-phase-1-code-review.md @@ -294,8 +294,8 @@ rejected (pre-existing lines, outside this plan's scope, but now inconsistent wi `npm run check` appends a twelfth `preflight` leg beyond todo 22's frozen eleven-leg order (AGENTS documents reality truthfully; benign additive deviation worth a docket note) · the plan-16 §6 per-item REPAIRED/SUPERSEDED dispositions exist only in ignored evidence · decision -specs live at `spec:decisions.*` while the DECISIONS registry reserves -`spec:protocol.decisions.*` — a namespace divergence the back-catalog fold will have to rule. +specs and the DECISIONS registry use `spec:decisions.*` — the back-catalog fold activates the +reserved IDs. --- diff --git a/reviews/07-self-hosting-phase-2-code-review.md b/reviews/07-self-hosting-phase-2-code-review.md new file mode 100644 index 0000000..812c1c3 --- /dev/null +++ b/reviews/07-self-hosting-phase-2-code-review.md @@ -0,0 +1,124 @@ +# 07 - Self-hosting phase-2 adversarial code review + +**Reviewed:** the full `main..HEAD` phase-2 diff through `8f5aabe`, plus this review's verified +remediation. The reviewed phase spans the TS-to-Markdown import surface, the atomic checkout +migration, the canonical-default flip, the decision fold, and the four corpus waves. + +**Method:** independent adversarial passes re-read the full branch history and changed surfaces, +then reproduced import loss, batch-publication, docs-agreement, fold, corpus, and package-contract +risks against the built product. Findings below are based on probes, not on prior task receipts. + +--- + +## Verdict + +**APPROVE WITH RECORDED FOLLOW-UPS.** The phase's historical checkout migration is atomic, the +canonical-default carrier rule agrees across its operative records, the fold is now a lean pointer +registry, and the corpus floor remains honest. Two correctness blockers in the durable import +surface and three related changes requests were fixed and re-probed. No BLOCKER remains open. + +The phase-close owner gates are deliberately not advanced here: this todo does not change their +ledger. Final clean-clone and installed-package final-SHA proof remain owned by todo 42; whole-phase +acceptance remains owned by todo 43. + +### What held + +- The checkout migration commit replaces all eleven checkout Spec carriers in one commit: each + `.sdp.md` addition pairs with the matching `.sdp.ts` deletion. No historical dual-carrier + checkout commit was found after the migration. +- The post-flip carrier rule is consistent in `AGENTS.md`, `CONTEXT.md`, the decision registry, + package README, concept docs, and the checkout walkthrough. The three carrier audit scripts pass. +- Decision Specs use the `spec:decisions.*` namespace and the current-law decision slots; the lean + registry points to each durable decision. The two empty former-diary headings were removed. +- The corpus reports 58 Specs, one Pack, 36 anchors, 95 nodes, and 180 edges with no self-hosting + validation errors or warnings. Its readiness distribution remains 7 `ready` and 51 `defined`. +- The installed package exposes the intended import/barrel symbols and the packed dry-run path. + +--- + +## Blocker findings (2) - remediated + +### B1 - Successful import silently dropped valid TS-authored content + +**Where:** `src/import/emit-markdown.ts` previously selected only the first constraint, example, +and `when` step. It also omitted string-form open questions and unsupported open-bag properties. + +**Probe:** valid TS carriers with two constraints, a string open question, two `when` steps, or a +structural title each reified without findings; their emitted Markdown lost data or changed structure. + +**Remediation:** `emitMarkdownSpec` now reifies its own document and requires exact authored-data +equality (normalizing only the canonical relation order and physical empty-relations spelling). +`importTypeScriptSpec` converts an unfaithful emission into `import/unsupported-construct` with no +emitted target. The public emitter throws its typed `MarkdownEmissionError` rather than returning a +silently changed document. + +**Re-probe:** the focused 38-test import/CLI/package suite passed. The log is quoted in +`.omo/evidence/self-hosting-phase-2/task-41-self-hosting-phase-2.review.md`. + +### B2 - A failed multi-source import could publish a partial migration + +**Where:** `src/cli/sdp.ts` previously wrote each output while walking sources. + +**Probe:** a directory containing a Pack carrier and an importable Spec exited nonzero but still +created the Spec's Markdown sibling. + +**Remediation:** CLI import now collects and validates all sources and targets before publication, +writes temporary files first, then publishes them together; a write/publish failure removes all +temporary and newly published targets. The Pack-plus-Spec regression now proves no sibling exists. + +**Re-probe:** `test/cli-import.test.ts` passed with the failed batch leaving its valid target absent. + +--- + +## Changes requested (3) - remediated + +### C1 - A requested path with no TS carrier falsely exited zero + +**Remediation:** `sdp import` emits `import/no-sources` and exits one when discovery produces no +`.sdp.ts` source. The focused CLI re-probe passed. + +### C2 - The public import API could return its source path as a target + +**Remediation:** `importTypeScriptSpec` requires a `.sdp.ts` source path and returns +`import/invalid-source-path` otherwise. The API re-probe passed. + +### C3 - Flip guidance still described checkout Markdown as typed/typechecked and MD-15 as TS-only + +**Remediation:** `CONTEXT.md`, `AGENTS.md`, the checkout walkthrough, and the carrier-truth rule +now distinguish Markdown-default authoring from the lawful TS carrier. All carrier audit scripts +passed after the repair. + +--- + +## Medium / low findings - owner-visible dispositions + +- **M1 - Packed import only exercises dry-run.** Deferred to todo 42's final installed-package + proof, which owns a final-SHA package run. The current package smoke still proves the tarball, + bin wiring, root exports, and dry-run non-write behavior; this review did not represent that as + a write-mode proof. +- **M2 - `npm run check` does not embed carrier-rule/truth scripts.** No code change here: the + phase deliberately owns those scripts as explicit flip/fold audits, and this review reran all + three. Todo 43 owns any final gate composition ruling. +- **M3 - Earlier clean-clone evidence predates the final corpus.** Deferred to todo 42 by design; + it must produce a fresh-clone receipt at the final audited SHA. This review does not reuse it. +- **M4 - G4/G5/G6 remain pending in the process ledger.** No change: the user explicitly forbade + ledger mutation in this todo. G7 remains accepted; todo 43 alone may record whole-phase owner + acceptance. +- **M5 - Six validation Specs name implementation entrypoints without individual anchors.** No + change: they are `defined`, do not author a delivery fact, and the phase's no-decorative-anchor + policy rejects inventing bindings just to make every prose locator derive `implemented`. + +--- + +## Verification addendum - remediation confirmed + +The re-probes ran after the remediation, not from worker reports: + +- `npm test -- test/import.test.ts test/cli-import.test.ts test/import-emit-markdown.test.ts test/import-round-trip.test.ts test/package-smoke.test.ts` passed: 5 files, 38 tests. +- `node ./check-carrier-rule.mjs`, `node ./check-carrier-truth.mjs`, and + `node ./check-self-hosting-gates.mjs` all passed. +- `npm run check` passed after the final review artifacts and repairs; its self-hosting report was + 58 Specs / 1 Pack / 36 anchors -> 95 nodes / 180 edges with zero errors and warnings. + +The detailed commands and captured output excerpts are in +`.omo/evidence/self-hosting-phase-2/task-41-self-hosting-phase-2.review.md`. diff --git a/reviews/08-self-hosting-phase-2-pre-merge-review.md b/reviews/08-self-hosting-phase-2-pre-merge-review.md new file mode 100644 index 0000000..d253901 --- /dev/null +++ b/reviews/08-self-hosting-phase-2-pre-merge-review.md @@ -0,0 +1,334 @@ +# 08 - Self-hosting phase-2 pre-merge branch review + +**Reviewed:** the full `main...feature/protocol-self-application-phase-2` diff at `f8648c4` +(42 commits, ~264 files), using the executed phase plan (`plans/18a-self-hosting-phase-2-execution.md`, +the durable copy of the orchestrator's plan) and its evidence ledgers as context. + +**Method:** five parallel adversarial passes, one per dimension — the pure import/emit surface, the +`sdp import` CLI verb and the `--exclude` surface, the Markdown carrier hardening and the +refusal-parity matrix, the projections/model/graph/reader surfaces, and the authored records +(specs corpus, decision fold, operative docs, audit scripts). Every finding marked **CONFIRMED** +was reproduced by a probe against the built product or the real repo state, not inferred from a +code read. This review deliberately does not re-report the findings of +`reviews/07-self-hosting-phase-2-code-review.md`; instead it re-probed those remediations (they +held — see "What held") and hunted for what that review missed. + +**Disposition:** compiled for a follow-up session. No product file was changed by this review. +The one working-tree repair made alongside it (banned temporal tokens in the uncommitted +`.omo` close notepad, which turned `check:temporal` red) is recorded as R-31 and was fixed +before commit. **The follow-up session then ran on this same branch** — see the remediation +addendum at the foot of this document and `plans/19-review-08-remediation.md` for the +per-finding dispositions. + +--- + +## Verdict + +**SOUND TO MERGE, WITH A RECORDED FOLLOW-UP DOCKET.** The phase's central guarantees held under +adversarial probing: the emitter's fidelity gate refused every lossy shape thrown at it (no silent +data change was found on any path), the migration's batch-atomicity held under normal failure, the +carrier-rule flip is consistent across every operative record, the fold's 21 pointers all resolve, +and the 58-spec corpus extracts clean with honest readiness. The majors below are defects in the +*durable* import/render surfaces — robustness, scoping, and diagnostic-quality debts — not in the +phase's fidelity or honesty claims. None of them corrupts authored data or falsifies a derived +fact on the repo's own pipeline today; two (R-4, R-5) can mislead or misfire in **consumer** +repos and should be treated as the head of the follow-up docket. + +--- + +## Major findings (8) + +### R-1 — A rollback removal failure crashes `sdp import`, abandons the rollback, and hides the original error + +**Where:** `src/cli/sdp.ts:284-288`. **CONFIRMED** (probe against `dist/cli/sdp.js`). + +The publish-failure catch block calls `remove(path, { force: true })` raw; `force` only swallows +ENOENT. An EACCES/EPERM/EBUSY on the first removal throws *out of the catch*, escaping +`runImport` as an uncaught exception: a Node stack trace (violating the CLI's own one-line +diagnostic law), the loop never reaches the remaining paths — so a **partially published target +persists**, which is exactly the state the review-07 B2 remediation exists to prevent — a temp +file leaks, and the original publish error is swallowed entirely. `runBuild`/`runView` treat this +identical scenario as a first-class law via `removeArtifact`/`removeArtifacts` +(`src/cli/sdp.ts:401-433`: never-throw + name-the-survivor, with injected-seam tests); import +bypasses that machinery. **Fix direction:** route the rollback through the same never-throw +removal helpers and report both the publish error and any surviving paths. + +### R-2 — The import root scan is unbounded: it recurses `node_modules`/`dist`/`generated`/dot-dirs and writes into them + +**Where:** `src/cli/sdp.ts:158-177` (`collectImportPaths`). **CONFIRMED** (two probes). + +Extraction discovery deliberately skips `node_modules`, `dist`, `generated`, `coverage`, and +dot-directories (`src/extract/discover.ts`); the import scan shares none of that, and `sdp import` +has no `--exclude`. Probed consequences: (1) `sdp import .` on a root whose `node_modules` +contains a dependency shipping a `*.sdp.ts` **writes a `.sdp.md` inside `node_modules` and exits +0**; (2) a directory symlink cycle under the root (routine in pnpm layouts) aborts the whole batch +with a bare `ELOOP`. Also an unbounded scan cost over `.git`/`node_modules`. **Fix direction:** +share the discovery exclusion set (and consider the repeatable `--exclude` flag) on the import +walk. + +### R-3 — A family of lawful TS Specs cannot be imported, and the refusal never names the divergent field + +**Where:** `src/import/emit-markdown.ts:230` (title), `:84-109` (intent/GWT nesting); +`src/import/markdown-fidelity.ts`. **CONFIRMED** (four probes). + +`title` is optional in the TS DSL (the repo's own parity fixtures omit it and reify cleanly), but +the emitter renders `` `# ${String(data.title)}` `` → `# undefined`; the fidelity gate then +rejects its own emission. A kind-`example` Spec without `intent` is likewise unimportable (the +ruled grammar's only home for the example `gwt` fence is inside `## Intent`, and an empty emitted +`## Intent` reifies as `intent: {}` ≠ absent), as is any authored-empty owner record, and +duplicate relation entries. In every case the CLI refuses with the same opaque +`import/unsupported-construct — … the emitted Markdown does not preserve the authored Spec data` +message: nothing names the construct or the field, so a batch-migration user gets no actionable +diagnosis. The refusal direction is *safe* (the gate is doing its job; no corrupt document lands), +but the verb's usefulness to adopters depends on refusing precisely. **Fix direction:** emit a +real refusal for absent-title/absent-intent shapes (or rule an emitter form for them), and make +`MarkdownEmissionError.reason` name the first divergent path. + +### R-4 — Protocol-builder recognition by generic relative specifiers can mint false anchors in consumer repos + +**Where:** `src/extract/reify.ts:204-215` (`protocolBuilderModuleSpecifiers`) wired through +`src/extract/index.ts:208`. **CONFIRMED** mechanism (probe: a file importing `spec`/`specId` from +its own `"./ids.js"` reifies a spec); flagged independently by two review dimensions. + +The self-hosting corpus needed the Protocol's own source to be anchor-eligible, so the raw-text +gate now trusts the generic specifiers `"./ids.js"`, `"../ids.js"`, `"./code-anchor.js"`, +`"./model/code-anchor.js"`, `"../model/code-anchor.js"`. Anchor candidacy spans every `.ts` file +in a consumer repo: a consumer file importing a symbol *named* `codeAnchor` from **their own** +module spelled `./code-anchor.js` gets its calls reified as `impl:` anchors → `satisfies` edges → +**derived `implemented` delivery facts from non-protocol code** — a name-coincidence path to false +machine truth in a system whose core law is "delivery facts are derived, never authored." +Conversely the set is depth-limited: a legitimate Protocol-internal anchor imported via +`"../../ids.js"` is not recognized and vanishes **silently** (fail-safe direction, but invisible +drift). Nothing today breaks the repo's own pipeline (all current sites sit at depth ≤ 1), and no +test pins the consumer-repo false-positive surface. **Fix direction:** scope the trusted-specifier +gate to the Protocol's own tree (or an explicit opt-in), and pin both directions with tests; this +deserves a recorded ruling. + +### R-5 — The uniform escaping policy corrupts values rendered inside fenced code and code spans + +**Where:** `src/projections/design-review.ts:485-506` (`renderOpenBag` via `renderDynamicRecord` +at `:129-135`); inline-code sites at `:467`, `:626`, `:628`. **CONFIRMED** (the branch's own test +asserts the corrupted output). + +The session-2 ruling ("one escaping policy covers every rendered field") was applied to +markdown-*literal* positions where escapes are never decoded. Open-bag values are escaped and then +`JSON.stringify`-ed into a ` ```json ` fence: authored `&` displays as `&`, `<` as `<`, and +`|` double-transforms to a displayed `a\|b` — visibly mangled data, and anything copying the JSON +out gets wrong values. `test/design-review.test.ts` locks the corruption in +(`'"surface": "Review <design> & safely."'` asserted inside the JSON block). The +escaping has zero protective value there — `JSON.stringify` already neutralizes everything that +could close the fence. The same category error applies to inline code spans (verification `mode`, +findings `where`/`validatorId`), where entities and backslash escapes render literally. **Fix +direction:** exempt markdown-literal positions (fenced code, code spans) from the entity/escape +policy — `JSON.stringify` is already the correct encoder for the fence — and update the ruling's +wording plus the now-stale "renders verbatim" comment (R-20). + +### R-6 — The Markdown frontmatter's reserved-vocabulary set misses the three delivery-fact names + +**Where:** `src/extract/markdown-yaml-policy.ts:12-21`. **CONFIRMED** (probe on all three names). + +`reservedEnvelopeKeys` bans `claim`/`deliveryFacts`/`nodeType`/`specKind`/`satisfies`/`verifies`/ +`belongsTo`/`models` but not `implemented`, `has-verifier`, `observed` (the TS carrier's +`RESERVED_DERIVED_PROPERTIES` includes all delivery-fact names, and the Markdown *body* set does +too — only the frontmatter envelope omits them). Authoring `implemented: true` in `.sdp.md` +frontmatter therefore classifies as `extract/unrecognized-property` ("looks like a typo") instead +of `extract/reserved-property` ("you authored derived vocabulary"). Not an honesty *bypass* — the +carrier is still refused with an error either way — but it breaks the same-class parity claimed +for `extract/reserved-property` by `specs/carrier/markdown-parser.sdp.md`; the parity matrix +passes only because its fixture happens to use `deliveryFacts`. **Fix direction:** add the +delivery-fact names (both spellings) to `reservedEnvelopeKeys` and widen the parity fixture. + +### R-7 — Two new `localeCompare` sorts violate the repo's own determinism law + +**Where:** `src/cli/sdp.ts:172-173` (the import directory walk) and +`src/import/markdown-fidelity.ts:30` (the fidelity gate's relation-sort normalization). +**CONFIRMED** as code fact (not reproduced across locales). + +`src/extract/serialize.ts:20` states the law: "Sorting is code-unit string comparison, never +`localeCompare`: locale-aware collation is environment-dependent." The import walk orders +multi-target processing — and therefore finding/report order — by ICU collation, which can differ +across machines. The fidelity-gate instance is locale-*consistent within a process* (both sides of +the equality sort with the same comparator), so it cannot flip an equality verdict, but it is the +same banned pattern in a surface whose whole point is determinism. **Fix direction:** replace both +with the existing `compareCodeUnits` pattern. + +### R-8 — `AGENTS.md`'s "Where to look" row still describes the pre-fold DECISIONS.md diary + +**Where:** `AGENTS.md:59`. **CONFIRMED.** + +The row advertises `docs/concept/DECISIONS.md` as "**the why-log** … (the ratified-name registry + +the MD-series, the R-series, the legacy D1–D6 shorthand, measured evidence) — rationale + open +tensions." Post-fold, that file is a ~48-line lean registry containing none of that — no R-series, +no measured-evidence section, no rationale — and its own header says rationale lives in git, +plans, and the Specs. The flip's anti-misleading pass swept the status header two paragraphs above +but missed this row. An agent onboarding via the top operative record is sent to the registry for +rationale and finds pointers, then may dig the deleted diary out of git and treat it as current. +**Fix direction:** rewrite the row for the lean registry (and the companion pointer at +`CONTEXT.md:7`, R-18). + +--- + +## Minor findings (12) + +- **R-9 — Batch diagnostics are fail-fast, not "per-file fail-collecting."** `src/cli/sdp.ts:246-254` + returns at the first source with an error finding/refusal/existing target; later sources' + findings are never computed. The phase plan's contract for the verb says per-file + fail-collecting (the authored spec is silent). A 50-file migration with 6 bad carriers takes 6 + runs to see all six diagnostics. **CONFIRMED.** +- **R-10 — Same physical source via two spellings defeats dedupe; dry-run diverges from execution.** + Dedupe is a string-keyed `Set` (`src/cli/sdp.ts:232`); a symlinked dir or case-differing path on + a case-insensitive FS validates clean, publishes the first twin, then fails blaming *its own + just-published file* ("Markdown target already exists"), rolls back, exit 1 — while `--dry-run` + prints both twins and exits 0. Rollback held in both probes; the message and the + preview/execution divergence mislead. **CONFIRMED.** +- **R-11 — A successful mixed Spec+Pack import (exit 0) guarantees the next `sdp build` fails.** + `src/import/import.ts:88-121`: the Spec twin is emitted with only an info finding; the Spec now + exists in both carriers, the TS source cannot be deleted (the Pack must stay TS), and nothing + tells the user the remedy is hand-editing the Spec out of the TS file. Next build: + 2 × `extract/duplicate-id`, graph not written. **CONFIRMED.** +- **R-12 — An explicitly named non-carrier operand is silently ignored** when any other source + emits (`src/cli/sdp.ts:161-165`, `:257-260`): `sdp import good.sdp.ts order.spd.ts` (typo) exits + 0 with no diagnostic for the typo'd operand. **CONFIRMED.** +- **R-13 — The multi-Spec refusal drops the Pack finding.** `src/import/import.ts:76-86`: the + `specs.length > 1` early return fires before `packFindings` is computed, so a two-Spec + Pack + module reports only `import/unsupported-construct`. **CONFIRMED.** +- **R-14 — `MarkdownEmissionError` is thrown by a public barrel export but not exported.** + `src/index.ts:9-11` exports `emitMarkdownSpec`, whose documented failure mode is the typed + error; consumers cannot `instanceof`-match it (dist probe: `MarkdownEmissionError === undefined` + on the barrel). **CONFIRMED.** +- **R-15 — The parity claim is id parity, not refusal parity.** `test/extract-parity.test.ts:187-195` + asserts exact `validatorId` containment (good) but neither severity nor extraction outcome; + probed inside a claimed same-class row: TS `unrecognized-property` is a **warning that still + extracts**, Markdown's is an **error that refuses the document** — the very degrade-vs-refuse + asymmetry the matrix's `non-static-section` rationale names as grounds for a non-claim. + `specs/carrier/markdown-parser.sdp.md` should say "finding-class parity" or carry the asymmetry + note. **CONFIRMED.** +- **R-16 — The frontmatter finding-cap summary carries the body's finding id.** + `src/extract/markdown-frontmatter.ts:191` caps a pure-frontmatter flood under + `extract/invalid-markdown-structure` (probed: 99 × invalid-frontmatter + 1 structure-id + summary). Pre-existing on `main`, carried through the refactor. **CONFIRMED.** +- **R-17 — Directive refusal line off-by-one** when `%` is not on the first frontmatter line: + `src/extract/markdown-frontmatter.ts:47-54`'s `search(/(?:^|\r?\n)%/u)` returns the offset of + the *newline before* the `%`, so the structured finding points one line high while the + yaml-native text beside it is right. Pre-existing; line correctness was this cluster's scope. + **CONFIRMED.** +- **R-18 — `CONTEXT.md:7` still says "rationale in `docs/concept/DECISIONS.md`,"** contradicting + the lean registry's own header. Companion of R-8. **CONFIRMED.** +- **R-19 — The D1–D6 table's "Canonical in" column is stale for D3/D5/D6** + (`docs/concept/DECISIONS.md:45-48`): they now have decision Specs, but a reader resolving a live + `(D5)` citation via the table never learns the decision record exists. **CONFIRMED.** +- **R-20 — Stale "renders verbatim" comment over the now-escaped example space** + (`src/projections/design-review.ts:351-352` vs `:363`) — the comment is the stale side of an + intent/realization drift. **CONFIRMED.** + +--- + +## Notes (11) + +- **R-21 — `import/*` findings ride severity `"info"`, outside the ratified severity vocabulary** + (`src/import/import.ts:19,42` vs `src/validate/contracts.ts:6`). The CLI is safe only because it + also keys on `result.emitted`; any consumer filtering `severity === "error"` or exhaustively + switching over `Severity` misclassifies import refusals. An undeclared third severity on a + shared channel — worth a deliberate ruling either way. +- **R-22 — Round-trip fixture blind spots:** no `rule`/`workflow`/`contract` kind, no + `design`/`ui`/`verification` section, no multi-target relation in the graph-level corpus; no + direct test that public `emitMarkdownSpec` throws; no test documenting the R-3 refusal family. +- **R-23 — Publication hygiene tail:** the never-overwrite guarantee is `existsSync`+`renameSync` + and `rename(2)` clobbers (TOCTOU window; `linkSync`+unlink would refuse atomically); temp files + leak on SIGKILL between temp-write and publish and nothing sweeps pid-scoped `*.tmp` strays; + import finding messages embed the path already carried in the structured `file` field (against + `formatFinding`'s own doc law); `--` is rejected so a path beginning with `--` is unreachable; + `src/cli/sdp.ts:249` is dead code; `targetExistsFinding`/`noSourcesFinding` fabricate `line: 1`. +- **R-24 — Control characters flow into spec data** (NUL/BEL in body prose, escaped CR in YAML + values) and land verbatim in `graph.json`. Same latitude as TS string literals — hygiene + observation, no parity break. +- **R-25 — Inherited-property lookups produce misleading uniqueness messages:** + `- constructor: x` under `## Design` refuses as "keys must be unique" on first occurrence + (`Object.prototype` members seen by `!== undefined` checks). Nothing is accepted; message-only. +- **R-26 — `renderDynamicRecord` sorts raw keys but emits escaped keys** + (`src/projections/design-review.ts:129-135`): deterministic, but rendered JSON keys can appear + non-lexicographic when escaping reorders (`a|x` vs `ab`). +- **R-27 — Two anchors import builders via the package name** (`src/graph/schema.ts:1`, + `src/codegen/contracts.ts:1` — a self-import cycle that works only through hoisting + the vitest + alias + tsup inlining), and `src/ids.ts:133-137` bypasses the builders with raw `as` casts. + Extraction re-validates, so no honesty hole; consistency debt. +- **R-28 — `test/self-hosting-graph.test.ts` is one 2,282-line `it`** with frozen absolute counts: + first failure masks the rest; every corpus edit churns the frozen block. Not tautological (it + asserts hand-written literals against real extraction) — a granularity/maintenance cost. +- **R-29 — The MD-3/MD-6 numbering gap is unexplained in the lean registry** (the retired-entries + note died with the diary); git-only archaeology required against the registry's own + "dispositions" promise. +- **R-30 — The example corpus shares the `decisions.` id namespace with the root corpus** + (`spec:decisions.order-lifecycle` in checkout-v1 vs 21 root `spec:decisions.*`). No collision — + the root pipeline excludes `examples/` — convention fragility only. +- **R-31 — (Resolved during this review.)** The uncommitted `.omo` close-notepad section carried + banned temporal tokens (an ISO date, `Wave F…`, a numbered plan-file reference), turning + `check:temporal` — and with it the whole green gate — red on the working tree while HEAD stayed + clean. Reworded before commit; the gate is green again. Kept on the record because notepad prose + is swept by the guard and this is the second time a close-note tripped it. + +--- + +## What held (re-probed, hunted, and clean) + +- **Review-07 remediations:** B1 holds and generalizes — multi-constraint, string open question, + multi-`when`, structural title, trailing-whitespace/CRLF/leading-`#`/embedded-fence/`---` + narratives, and `flows`/`description` injection shapes all refuse loudly; unicode and + line-separator prose round-trips faithfully; no silent data change was found on any path. B2 + holds under normal failure (validation precedes writes; failed batches leave zero targets); C1 + and C2 hold; `--dry-run` writes nothing on every exercised path. +- **The parity matrix is rigorous where it counts:** exact `validatorId` assertions per cell, + rationale required for every non-claim row, duplicate-id exercised through the full `extract()` + path. The review-06 grammar cluster is closed for real (non-mapping-root findings preserved, + duplicate `When` once, exact `...`, literal terminal `#`, per-limit single summaries, linear + flood behavior: 10k bad headings ≈ 309 ms). The acceptance subset refuses directives, tags, + anchors, aliases, complex/duplicate/merge keys, and the review-06 scalar-spelling gaps. +- **Escaping is injective and deterministic** (the `&`-first order actually fixed a pre-existing + non-injectivity), ordering in the render path is code-unit only, and the permuted-insertion + determinism test is a genuine byte-identity check. +- **Anchor honesty:** all 19 new `impl:protocol.*` anchors target existing spec ids, sit within + the test-enforced proximity of a named real entrypoint, and no validator was silenced; + `validateGraph` over the root corpus: zero findings. `schemaVersion` stays `0.4.0` with no + hidden shape change. +- **The records:** the post-flip carrier sentence is verbatim-consistent across every active + surface probed (AGENTS.md, CONTEXT.md, the registry, README, concept docs, JTBD stories, the + checkout README, the decision spec); no active doc states the interim rule; all three audit + scripts pass; all 21 registry pointers resolve with matching ids; the pinned fold-target map + matches the authored `refines`/`decidedBy` edges on every spot-checked spec; 15 sampled corpus + specs are substantive with no filler; checkout-v1 is exactly 11 `.sdp.md` + the lawful TS pack + manifest, and its README figures reproduce exactly. +- **The gates:** the full `npm run check` chain is green at HEAD and after the R-31 repair; + focused suites across all five dimensions pass (import/CLI/package 38+, parity/hardening/reifier + 120, projections/reader/self-hosting 59). + +## Suggested follow-up order + +1. R-1, R-2 (the two robustness holes in the durable verb), then R-3 (diagnosability) — one + session, TDD, with R-9/R-10/R-11/R-12/R-13 riding as the same verb's diagnostic cluster. +2. R-4 (the anchor-trust scoping ruling) and R-6 (reserved-vocabulary parity) — small, high-honesty + value; R-15's claim-wording repair lands with R-6. +3. R-5 + R-20 + R-26 (the escaping-policy scope repair) — one ruled projection session. +4. R-7, R-14, R-16, R-17 as a small hygiene batch; R-8, R-18, R-19, R-29 as one records sweep. +5. The notes (R-21 … R-30) enter the docket for disposition, not necessarily for work. + +--- + +## Remediation addendum — the docket closed on this branch + +The follow-up session ran before merge rather than after it, so the docket this review opened does +not survive into `main`. The per-finding disposition table, the three architecture rulings it +forced (anchor-builder trust, the import transaction, Markdown rendering contexts), and the +verification record are `plans/19-review-08-remediation.md`. + +- **Fixed:** R-1 … R-23, R-25, R-26, R-27 (partial, with a named bootstrap exception), R-29. +- **Recorded only, no invented policy:** R-24 (control-character latitude matches TS string + literals), R-28 (corpus-test granularity — owned by the named tests-to-executable-specs + program), R-30 (root and example corpora are separate extraction roots). +- **Already resolved:** R-31. + +Three rulings are worth reading before touching those surfaces again, because each closed a +finding by fixing a *contract* rather than a call site: builder trust is physical module identity, +never a raw relative spelling (R-4); import is a scan → plan → prepare → publish transaction whose +publication is an atomic hard link and whose rollback never throws (R-1, R-2, R-9 … R-13, R-23); +and Markdown escaping follows syntactic context — prose escapes, fenced JSON and inline code carry +literal data under their own encoders (R-5, R-20, R-26). diff --git a/reviews/README.md b/reviews/README.md index 9f65c8c..797e629 100644 --- a/reviews/README.md +++ b/reviews/README.md @@ -14,6 +14,8 @@ in the finished Protocol, review artifacts become graph projections. | `04-post-split-adversarial-review.md` | the post-split adversarial review (F1–F7; D7 kind-aware floor, D8 `.spec.ts` collision, the resolvable-now assessment) | `plans/03` agenda + `DECISIONS.md` | | `05-plan-finalization-prompt.md` | the prompt that launched the pre-grill fold session (2026-06-10: Fold-A, Fold-B, this archive) | `plans/04` §3–§4 (executed) | | `06-self-hosting-phase-1-code-review.md` | the multi-agent adversarially-verified code review of the self-hosting phase-1 implementation (post-Gate-4, pre-F1–F4: 4 highs — todo-14 generated-state contract, unsupported-block acceptance, preflight recovery prefix, untested grammar limits — plus 8 mediums and the low/info tail) | plan 17's review-06 remediation reconciliation ledger (all highs/mediums fixed + verified in the doc's addendum; low tail dispositioned) | +| `07-self-hosting-phase-2-code-review.md` | the archived phase-2 adversarial code review (two import-surface blockers — silent content loss, partial batch publication — plus three change requests, all remediated and re-probed) | plan 18's remediation record and docket (`plans/18a` ledgers) | +| `08-self-hosting-phase-2-pre-merge-review.md` | the five-dimension pre-merge branch review of the full phase-2 diff (R-1…R-31: 8 majors, 12 minors, 11 notes; the 06/07 remediations re-probed and held) | `plans/19` — the follow-up session ran before merge: every accepted finding fixed, three surfaces re-ruled, the rest dispositioned in the review's own remediation addendum | The gen-1 (`@libar-dev/architect`) formal-spec study from the same arc was chat-only; its takeaway is formalized in `plans/04` §0/§2 ("lineage is evidence, not template" — no patterns transferred). diff --git a/specs/carrier/envelope-contract.sdp.md b/specs/carrier/envelope-contract.sdp.md index 1fd03f2..972f3b6 100644 --- a/specs/carrier/envelope-contract.sdp.md +++ b/specs/carrier/envelope-contract.sdp.md @@ -5,6 +5,7 @@ altitude: feature readiness: defined relations: refines: spec:carrier.markdown-authoring + decidedBy: spec:decisions.envelope-grammar-posture --- # The Markdown envelope is explicit and bounded diff --git a/specs/carrier/markdown-authoring.sdp.md b/specs/carrier/markdown-authoring.sdp.md index f8d5f55..038bb4a 100644 --- a/specs/carrier/markdown-authoring.sdp.md +++ b/specs/carrier/markdown-authoring.sdp.md @@ -5,6 +5,9 @@ altitude: feature readiness: defined relations: dependsOn: spec:carrier.markdown-parser + decidedBy: + - spec:decisions.sdp-ts-extension + - spec:decisions.carrier-ruling --- # Markdown authoring enters the one graph diff --git a/specs/carrier/markdown-parser.sdp.md b/specs/carrier/markdown-parser.sdp.md index e000989..db10a09 100644 --- a/specs/carrier/markdown-parser.sdp.md +++ b/specs/carrier/markdown-parser.sdp.md @@ -16,3 +16,11 @@ relations: ## Behavior - rule: The parser accepts only the ruled heading grammar and excludes one malformed carrier while continuing healthy siblings. +- rule: The ruled Markdown parser has bounded finding-class parity with the TypeScript carrier for `extract/non-static-envelope`, `extract/invalid-id`, `extract/duplicate-id`, `extract/reserved-property`, `extract/unowned-prose`, and `extract/unrecognized-property`; the shared validator ID is the claim, while severity and extract-versus-refuse outcomes remain carrier-specific. +- rule: Named non-claim — `extract/parse-error` remains distinct because YAML/frontmatter parsing has no TypeScript parser-diagnostic analogue. +- rule: Named non-claim — `extract/non-static-section` remains distinct because TypeScript degrades optional section properties while Markdown refuses malformed documents whole. +- rule: Named non-claim — `extract/unrecognized-statement` remains distinct because Markdown owns prose and structures, not TypeScript statement recognition. +- rule: Named non-claim — `extract/misplaced-authoring` remains distinct because Markdown has no executable authoring-call surface. + +## Verification — executable +- `test/extract-parity.test.ts` executes the settled finding-class parity matrix, including the six same-class findings, their carrier-specific outcomes, and four named non-claims. diff --git a/specs/carrier/prose-ownership-rule.sdp.md b/specs/carrier/prose-ownership-rule.sdp.md index 662c088..9ccc07c 100644 --- a/specs/carrier/prose-ownership-rule.sdp.md +++ b/specs/carrier/prose-ownership-rule.sdp.md @@ -5,6 +5,7 @@ altitude: story readiness: defined relations: refines: spec:carrier.markdown-authoring + decidedBy: spec:decisions.prose-ownership --- # Every prose edge has one owner diff --git a/specs/carrier/sdp-import.round-trip.sdp.md b/specs/carrier/sdp-import.round-trip.sdp.md new file mode 100644 index 0000000..fa2de94 --- /dev/null +++ b/specs/carrier/sdp-import.round-trip.sdp.md @@ -0,0 +1,22 @@ +--- +id: spec:carrier.sdp-import.round-trip +kind: example +altitude: story +readiness: ready +relations: + refines: spec:carrier.sdp-import + verifies: spec:carrier.sdp-import +--- +# A TypeScript carrier survives import as an equal Markdown graph + +## Intent +- outcome: Execute the import round-trip against a TypeScript-carrier fixture. + +```gwt +Given a TS-carrier spec +When importTypeScriptSpec runs +Then the emitted Markdown re-parses to an equal graph +``` + +## Verification — executable +- The bound test runs `assertAuthoredRoundTrip` against the import behavior fixture. diff --git a/specs/carrier/sdp-import.sdp.md b/specs/carrier/sdp-import.sdp.md index f14f496..6b40644 100644 --- a/specs/carrier/sdp-import.sdp.md +++ b/specs/carrier/sdp-import.sdp.md @@ -2,11 +2,28 @@ id: spec:carrier.sdp-import kind: behavior altitude: feature -readiness: idea +readiness: ready relations: refines: spec:carrier.markdown-authoring --- -# Existing intent can later be imported into the ruled carrier +# TypeScript-carried Specs can become Markdown twins ## Intent -- outcome: Name import as deferred work without claiming an emitter exists. +- actor: A coding agent or maintainer. +- outcome: Convert a TypeScript-carrier Spec into an idiomatic `.sdp.md` twin beside its source. +- value: The TypeScript DSL survives as an import source while Markdown becomes the authored twin. + +## Behavior +- rule: Import writes the emitted Markdown sibling beside the TypeScript carrier and never deletes the source carrier. +- rule: Import refuses an existing Markdown sibling rather than overwriting it. +- rule: Refusal outcomes retain the TypeScript reifier findings and add import-local findings honestly. +- rule: Import consumes the TypeScript reifier so source acceptance follows one validation path. +- rule: A batch scans only bounded source directories, canonicalizes physical carrier identity, and computes every refusal and target collision before publishing any sibling. +- rule: Publication prepares exclusive temporary siblings and atomically creates targets without clobbering; rollback attempts every artifact, reports survivors, and never deletes a TypeScript source. + +## Example space +```gwt-vocabulary +Given a TS-carrier spec +When importTypeScriptSpec runs +Then the emitted Markdown re-parses to an equal graph +``` diff --git a/specs/consumers/agent-surface.sdp.md b/specs/consumers/agent-surface.sdp.md new file mode 100644 index 0000000..c704f7e --- /dev/null +++ b/specs/consumers/agent-surface.sdp.md @@ -0,0 +1,20 @@ +--- +id: spec:consumers.agent-surface +kind: behavior +altitude: feature +readiness: defined +relations: + refines: spec:consumers.projections-model + decidedBy: spec:decisions.agent-surface-scripts-graph +--- +# Agents script a visible typed graph + +## Intent +- outcome: Let an agent obtain and compose graph context without rebuilding joins or navigating a fixed verb wall. + +## Behavior +- rule: The agent surface exposes a visible, self-describing typed graph through the CLI; the schema is the contract and agents script the graph directly. +- rule: The reader constructs decoded joins and claim taxonomy once, then returns plain composable data without persisting graph state. +- rule: Entry adapters bridge strings, files, and changesets to curated graph context; file-level blast radius names coverage-unknown files rather than implying exhaustive reach. +- rule: Context efficiency is an empirical result: a measured comparison may show structured graph context uses fewer supplied tokens than a comparable raw-text workflow while preserving the task-relevant result. +- rule: Measured evidence: a multi-probe agent comparison used about one fifth of the tokens of a comparable grep or verb-API workflow while preserving task-relevant conclusions. diff --git a/specs/consumers/design-review.sdp.md b/specs/consumers/design-review.sdp.md new file mode 100644 index 0000000..4f503ce --- /dev/null +++ b/specs/consumers/design-review.sdp.md @@ -0,0 +1,19 @@ +--- +id: spec:consumers.design-review +kind: behavior +altitude: feature +readiness: defined +relations: + refines: spec:consumers.projections-model +--- +# Design Review renders graph context without becoming a gate + +## Intent +- outcome: Give a human a regenerable, contextual view for deciding how to state readiness without recording approval as graph truth. + +## Behavior +- rule: Design Review renders a Spec or Pack in context with relations, bindings, delivery badges, design questions, and findings from the graph. +- rule: The review is a pure projection that resolves through ordinary source edits, git, and conformance checks; it stores no findings and writes no canonical source. +- rule: A human may use the review context when stating readiness, while validators check only the structural readiness floor and never record or require review approval. +- rule: The MVP view is deterministic generated Markdown with an index and pages for Specs and Packs; richer visual representations remain outside this behavior. +- rule: Rendering encodes by Markdown syntax context: prose and table fields escape structural characters, fenced JSON preserves authored keys and values through JSON encoding, and inline code uses a delimiter that preserves literal backticks. diff --git a/specs/consumers/edit-model.sdp.md b/specs/consumers/edit-model.sdp.md new file mode 100644 index 0000000..c19e316 --- /dev/null +++ b/specs/consumers/edit-model.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:consumers.edit-model +kind: behavior +altitude: feature +readiness: defined +relations: + refines: spec:consumers.projections-model +--- +# Views compose scoped intent instead of patching canonical source + +## Intent +- outcome: Let a view frame a requested change without giving derived surfaces a direct write path to canonical source. + +## Behavior +- rule: A view composes scoped intent, bounded by a Spec, its neighbors, a Pack, or open questions, and hands that intent to an agent. +- rule: The agent edits source as a human would, git records the ordinary edit, and the same conformance and honesty checks evaluate it. +- rule: Lifecycle changes such as splitting, combining, refining, or deleting are ordinary source and git edits rather than structured patches from a derived view. +- rule: No single realizing entrypoint exists for intent composition; this defined behavior records design intent and has no code anchor or verifier. diff --git a/specs/consumers/projections-model.sdp.md b/specs/consumers/projections-model.sdp.md new file mode 100644 index 0000000..ec2642e --- /dev/null +++ b/specs/consumers/projections-model.sdp.md @@ -0,0 +1,25 @@ +--- +id: spec:consumers.projections-model +kind: model +altitude: feature +readiness: defined +relations: + refines: spec:protocol.self-hosting + decidedBy: spec:decisions.mcp-deferred +--- +# Projections fan out from one graph without becoming truth stores + +## Intent +- outcome: Give agents and humans consumer-specific views while preserving the repository as the only canonical source. + +## Model +- **projection** — A pure, disposable, regenerable function of the graph that produces a consumer artifact without becoming a second source of truth. +- **curated graph** — The authored architectural read model of declared intent and anchored bindings, valued for editorial sparsity. +- **impact graph** — A separately derived code-structure surface for exhaustive usage and blast-radius questions, valued for exhaustiveness and never promoted into architecture. +- **reader** — The thin typed front door that decodes graph joins and taxonomy once, returns composable data, and persists nothing. +- **curation** — The deliberate difference between the sparse curated graph and the code-structure surface; it is not drift. +- **measured curation** — In a measured comparison, the curated graph selected from single-digit to about one quarter of the mechanical impact-graph surface. +- **discipline** — A lens or projection that filters or groups Specs by kind or section; it is not a phase to pass through. +- **release** — A tagged set surfaced as a git-tag projection. +- **baseline** — A named approved snapshot whose signed git tag is the approval artifact, with approval remaining outside the authored model. +- **phase / iteration / milestone** — Descriptive vocabulary for optional roadmap projections, never gates or enforced sequences. diff --git a/specs/consumers/reader.sdp.md b/specs/consumers/reader.sdp.md new file mode 100644 index 0000000..a07bca8 --- /dev/null +++ b/specs/consumers/reader.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:consumers.reader +kind: behavior +altitude: feature +readiness: defined +relations: + refines: spec:consumers.agent-surface +--- +# The reader bridges agent entry points to composable graph context + +## Intent +- outcome: Let agents enter the curated graph from the strings, files, and changesets they already have without rebuilding its joins or taxonomy. + +## Behavior +- rule: `createReader` constructs a fresh thin typed loader that decodes graph joins, claims, delivery facts, derived readiness, and validation findings once, then returns plain composable data without persisting state. +- rule: `findByConcept` and `byFile` bridge strings and extraction-root-relative files to the graph's recorded context. +- rule: The reader's `blastRadius` surface maps changed files to directly impacted Specs and Packs, their explicit one-hop at-risk neighbors, and every coverage-unknown file. +- rule: File-level blast radius reports curated graph reach without claiming exhaustive symbol-level usage reach. diff --git a/specs/decisions/adopt-the-nouns.sdp.md b/specs/decisions/adopt-the-nouns.sdp.md new file mode 100644 index 0000000..40a8610 --- /dev/null +++ b/specs/decisions/adopt-the-nouns.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.adopt-the-nouns +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:protocol.self-hosting +--- +# Delivery nouns remain familiar without workflow gates + +## Intent +- outcome: Keep the Protocol legible to delivery practitioners without adopting a lifecycle machine. + +## Decision +- context: Shared delivery vocabulary is useful, but process-state language hides epistemic distinctions. +- decision: The Protocol adopts established delivery nouns and rejects process state-machine and lifecycle gating. +- rationale: Invented terminology taxes users, while workflow states reverse the Protocol's conformance-only boundary. +- consequence: Terms must be concrete, unambiguous, and carry authored-versus-derived status where it matters. diff --git a/specs/decisions/agent-surface-scripts-graph.sdp.md b/specs/decisions/agent-surface-scripts-graph.sdp.md new file mode 100644 index 0000000..4ef48fd --- /dev/null +++ b/specs/decisions/agent-surface-scripts-graph.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.agent-surface-scripts-graph +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:consumers.agent-surface +--- +# Agents script the visible graph + +## Intent +- outcome: Give agents composable graph context without a fixed command vocabulary becoming the model. + +## Decision +- context: Agents need decoded context and entry adapters without rebuilding graph joins. +- decision: The typed graph is the visible contract and agents script it directly through a thin reader surface. +- rationale: A verb wall duplicates graph semantics and hides composable data behind commands. +- consequence: Entry adapters expose curated context while coverage gaps remain explicit rather than implied exhaustive. diff --git a/specs/decisions/binding-not-liveness.sdp.md b/specs/decisions/binding-not-liveness.sdp.md new file mode 100644 index 0000000..ea7f5e0 --- /dev/null +++ b/specs/decisions/binding-not-liveness.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.binding-not-liveness +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:model.anchors +--- +# Bindings state existence, not liveness + +## Intent +- outcome: Make realization signals useful without overstating what source bindings prove. + +## Decision +- context: Anchors can resolve code and tests without proving reachability, execution, or approval. +- decision: Delivery facts record bindings and enabled verifier existence; coverage gaps and human readiness practice remain explicit without becoming graph facts. +- rationale: Renaming useful delivery facts or recording approval primitives either weakens drift signals or reverses the one-primitive boundary. +- consequence: Impact reports name coverage-unknown files and `ready` remains a declared statement above a structural floor. diff --git a/specs/decisions/carried-evidence.sdp.md b/specs/decisions/carried-evidence.sdp.md new file mode 100644 index 0000000..a3a8804 --- /dev/null +++ b/specs/decisions/carried-evidence.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.carried-evidence +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:validation.readiness-floor +--- +# Promoted evidence must carry its own evidence + +## Intent +- outcome: Prevent empty promoted Specs and relation targets from satisfying an evidence floor. + +## Decision +- context: Promotion and constraints preserve meaning only when the promoted target carries the matching kind evidence. +- decision: Promoted evidence counts only when the promoted Spec holds its natural evidence; authoring-shape honesty rejects authored delivery facts and external `doc:` targets remain deferred. +- rationale: Counting empty children or wrong-kind constraints makes a structural floor pass without content, while readiness gates and premature external target types add the wrong contract. +- consequence: The floor checks resolved target shape, and unresolved external decision links stay outside the current relation grammar. diff --git a/specs/decisions/carrier-ruling.sdp.md b/specs/decisions/carrier-ruling.sdp.md new file mode 100644 index 0000000..3e19433 --- /dev/null +++ b/specs/decisions/carrier-ruling.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.carrier-ruling +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:carrier.markdown-authoring +--- +# Markdown is the default Spec carrier + +## Intent +- outcome: Give every Spec kind one readable canonical authoring surface without losing a lawful escape hatch. + +## Decision +- context: The carrier must express all Spec kinds without creating an unbounded tooling obligation or a dual-source truth path. +- decision: Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option. +- rationale: An owned grammar and a permanent kind split both add surface cost without a demonstrated expressive gain, while retiring the DSL removes a useful bounded option. +- consequence: Each ID has one canonical surface, and Markdown tooling is the default path for authored Specs. diff --git a/specs/decisions/content-only-sections.sdp.md b/specs/decisions/content-only-sections.sdp.md new file mode 100644 index 0000000..dfca310 --- /dev/null +++ b/specs/decisions/content-only-sections.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.content-only-sections +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:model.spec-sections +--- +# Sections carry content while relations carry links + +## Intent +- outcome: Keep inline detail and promoted Specs from representing the same fact twice. + +## Decision +- context: Behavior content can mature from prose to structured evidence or into a standalone matching-kind Spec. +- decision: Sections contain local content only; promotion moves content exclusively and relations state the linkage. +- rationale: Reference unions and duplicate parent lists force consumers to branch and leave double-linkage drift legal. +- consequence: Promoted children preserve readiness evidence through their own content and authored relations. diff --git a/specs/decisions/envelope-grammar-posture.sdp.md b/specs/decisions/envelope-grammar-posture.sdp.md new file mode 100644 index 0000000..7f7845a --- /dev/null +++ b/specs/decisions/envelope-grammar-posture.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.envelope-grammar-posture +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:carrier.envelope-contract +--- +# The Protocol owns the envelope grammar + +## Intent +- outcome: Keep authored envelope meaning stable while retaining a replaceable parsing representation. + +## Decision +- context: YAML parsing behavior alone cannot define the Protocol's authored contract. +- decision: The Protocol owns a bounded envelope grammar and parser policy; the pinned YAML library is a swappable representation behind that contract. +- rationale: Permissive parsing lets library behavior define meaning, while an owned YAML parser recreates the rejected grammar-maintenance burden. +- consequence: Unsupported YAML constructs are refused within explicit resource bounds instead of silently becoming carrier semantics. diff --git a/specs/decisions/exclusion-contract.sdp.md b/specs/decisions/exclusion-contract.sdp.md new file mode 100644 index 0000000..649fd44 --- /dev/null +++ b/specs/decisions/exclusion-contract.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.exclusion-contract +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:extraction.excludes +--- +# Consumer exclusions stay exact + +## Intent +- outcome: Keep consumer-selected omissions precise and unsurprising. + +## Decision +- context: Exclusion input crosses from a consumer into canonical source discovery. +- decision: Consumers declare exclusions as exact root-relative POSIX path prefixes. +- rationale: Semantic globbing and path normalization are rejected because they make an omission broader or different from the path the consumer supplied. +- consequence: A prefix excludes only itself and slash-delimited descendants; malformed paths, including Windows-drive absolutes, are refused. diff --git a/specs/decisions/executable-meta-model.sdp.md b/specs/decisions/executable-meta-model.sdp.md new file mode 100644 index 0000000..3568509 --- /dev/null +++ b/specs/decisions/executable-meta-model.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.executable-meta-model +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:protocol.self-hosting +--- +# The Protocol is an executable meta-model + +## Intent +- outcome: Make delivery intent conform to one typed, self-validating contract. + +## Decision +- context: Delivery tools can describe work without making their model executable. +- decision: The Protocol models authored Specs, Packs, and anchors in typed code, derives one graph, and checks conformance and honesty. +- rationale: Executable specs alone and workflow tooling omit the meta-model contract. +- consequence: The Protocol is deterministically validated without judging content quality or enforcing workflow. diff --git a/specs/decisions/kind-conditional-floor.sdp.md b/specs/decisions/kind-conditional-floor.sdp.md new file mode 100644 index 0000000..0d25492 --- /dev/null +++ b/specs/decisions/kind-conditional-floor.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.kind-conditional-floor +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:validation.readiness-floor +--- +# Readiness evidence follows the Spec kind + +## Intent +- outcome: Make stated readiness structurally honest without turning the floor into a quota. + +## Decision +- context: Kinds have different natural evidence, while structural maturity clauses apply across every Spec. +- decision: The readiness floor combines cumulative kind-blind clauses with one kind-conditional evidence clause at each rung. +- rationale: Defined-only evidence and uniform evidence rules either leave padding legal or erase meaningful kind distinctions. +- consequence: Floor rows are monotonic, promotion-neutral, and converge honestly where a kind has no stronger form. diff --git a/specs/decisions/mcp-deferred.sdp.md b/specs/decisions/mcp-deferred.sdp.md new file mode 100644 index 0000000..d0b7bb3 --- /dev/null +++ b/specs/decisions/mcp-deferred.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.mcp-deferred +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:consumers.projections-model +--- +# MCP integration remains deferred + +## Intent +- outcome: Preserve a clean projection model without prematurely fixing an application integration surface. + +## Decision +- context: The graph already supports typed agent and human projections without an MCP transport. +- decision: MCP integration is deferred until a concrete caller establishes its boundary and contract. +- rationale: Adding an MCP surface without a caller invents verbs and persistence choices outside the projection model. +- consequence: Consumers use the current graph and reader surfaces while MCP remains designed-in rather than claimed. diff --git a/specs/decisions/one-primitive.sdp.md b/specs/decisions/one-primitive.sdp.md new file mode 100644 index 0000000..6fa4ff5 --- /dev/null +++ b/specs/decisions/one-primitive.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.one-primitive +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:model.core-model +--- +# One Spec carries named delivery coordinates + +## Intent +- outcome: Preserve one durable authored primitive while making familiar delivery forms precise. + +## Decision +- context: Delivery statements vary by truth category, scope, and maturity without needing separate artifact types. +- decision: A Spec is enriched in place with kind, altitude, and readiness; familiar delivery nouns are named coordinates on that primitive. +- rationale: Separate types per coordinate combination multiply shapes and break enrich-in-place identity. +- consequence: Domains and capabilities are projections or Packs, not extra altitudes. diff --git a/specs/decisions/one-validation-path.sdp.md b/specs/decisions/one-validation-path.sdp.md new file mode 100644 index 0000000..0d2ba8e --- /dev/null +++ b/specs/decisions/one-validation-path.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.one-validation-path +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:validation.two-check-families +--- +# Validation follows one graph + +## Intent +- outcome: Keep conformance and honesty checks aligned with the source the graph actually represents. + +## Decision +- context: Source can be statically reified without matching what an executing import would evaluate. +- decision: Validators consume the derived graph through one path: source, extraction, graph, then checks. +- rationale: A parallel import-time validation path can approve values absent from the graph. +- consequence: Typed authoring feedback and extraction findings remain distinct from graph validation rather than becoming a second validator. diff --git a/specs/decisions/pack-reified.sdp.md b/specs/decisions/pack-reified.sdp.md new file mode 100644 index 0000000..33c275a --- /dev/null +++ b/specs/decisions/pack-reified.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.pack-reified +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:model.pack-aggregate +--- +# Packs group review context without becoming truth + +## Intent +- outcome: Let related Specs be reviewed together without introducing another truth-bearing artifact. + +## Decision +- context: Delivery work needs a cross-cutting aggregate that is distinct from refinement. +- decision: A Pack declares membership and framing while stating no system truth; Specs may belong to many Packs. +- rationale: Treating a Pack as a truth primitive or a refinement parent confuses grouping with authored intent. +- consequence: Review context remains disposable while Spec relations retain semantic hierarchy. diff --git a/specs/decisions/point-per-example.sdp.md b/specs/decisions/point-per-example.sdp.md new file mode 100644 index 0000000..b9e5747 --- /dev/null +++ b/specs/decisions/point-per-example.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.point-per-example +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:model.spec-sections +--- +# Each example binds one point + +## Intent +- outcome: Keep example-space coverage and outcome witnesses unambiguous while preserving compact authoring views. + +## Decision +- context: A single example must remain one witness in its parent's typed example space. +- decision: An example binds exactly one point; table syntax may expand statically into sibling examples and renderers may project siblings as a table. +- rationale: Point sets make concreteness and witness semantics conditional, while banning table sugar taxes a surface layer that can translate honestly. +- consequence: The graph never stores multi-point examples even when a carrier offers tabular authoring. diff --git a/specs/decisions/prose-ownership.sdp.md b/specs/decisions/prose-ownership.sdp.md new file mode 100644 index 0000000..15139d6 --- /dev/null +++ b/specs/decisions/prose-ownership.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.prose-ownership +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:carrier.prose-ownership-rule +--- +# Prose belongs to typed graph owners + +## Intent +- outcome: Preserve free prose for projections without making its attachment ambiguous or forcing consumers to re-parse files. + +## Decision +- context: Document prose needs a stable graph home when section structure evolves. +- decision: Free prose is stored as a narrative or a description on its typed owner; unowned prose is refused. +- rationale: File pointers force consumer re-parsing, while heading-path keys make churned document structure carry identity. +- consequence: Prose remains graph content inside typed shapes and ambiguous attachment fails loudly. diff --git a/specs/decisions/protocol-naming.sdp.md b/specs/decisions/protocol-naming.sdp.md new file mode 100644 index 0000000..3d31deb --- /dev/null +++ b/specs/decisions/protocol-naming.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.protocol-naming +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:protocol.self-hosting +--- +# The meta-model is a software delivery protocol + +## Intent +- outcome: Name the product and its meta-layer without implying workflow control. + +## Decision +- context: The meta-layer needs a name that communicates a conformance contract rather than a process engine. +- decision: The product is the Libar Software Delivery Protocol, shortened to the Protocol; `sdp` names its CLI. +- rationale: Protocol names an executable conformance contract more honestly than process while retaining process for the modeled activity. +- consequence: Product, package, repository, and CLI names stay aligned around the Protocol. diff --git a/specs/decisions/sdp-ts-extension.sdp.md b/specs/decisions/sdp-ts-extension.sdp.md new file mode 100644 index 0000000..5db1a59 --- /dev/null +++ b/specs/decisions/sdp-ts-extension.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.sdp-ts-extension +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:carrier.markdown-authoring +--- +# Spec extensions identify the carrier without colliding with tests + +## Intent +- outcome: Keep authored Spec files recognizable to tools and safe beside ordinary test conventions. + +## Decision +- context: A carrier filename must distinguish authored Specs from test files and remain useful when files are colocated. +- decision: Markdown Specs use `.sdp.md`; `.sdp.ts` names the surviving TypeScript DSL import source and lawful per-ID option. +- rationale: Test-glob extensions and path-only conventions either misclassify Specs or hide their identity. +- consequence: Carrier-specific tooling can target the compound extension without changing the `Spec` model name. diff --git a/specs/decisions/typing-law.sdp.md b/specs/decisions/typing-law.sdp.md new file mode 100644 index 0000000..1cf3b74 --- /dev/null +++ b/specs/decisions/typing-law.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:decisions.typing-law +kind: decision +altitude: feature +readiness: defined +relations: + refines: spec:model.spec-sections +--- +# Floor-read sections are closed typed shapes + +## Intent +- outcome: Give authors guardrails exactly where readiness and honesty checks depend on section content. + +## Decision +- context: A fixed list of typed sections becomes stale when the readiness floor evolves. +- decision: Every section read by a floor clause has a closed typed shape; unsettled design and ui surfaces remain open. +- rationale: Closed shapes block authored-fact smuggling and provide useful authoring guidance without prematurely fixing unsettled surfaces. +- consequence: A newly floor-read section becomes typed by the criterion, not by a frozen list. diff --git a/specs/extraction/claim-taxonomy.sdp.md b/specs/extraction/claim-taxonomy.sdp.md new file mode 100644 index 0000000..88ddbef --- /dev/null +++ b/specs/extraction/claim-taxonomy.sdp.md @@ -0,0 +1,19 @@ +--- +id: spec:extraction.claim-taxonomy +kind: model +altitude: feature +readiness: defined +relations: + refines: spec:extraction.derive-graph +--- +# Graph claims retain their epistemic source + +## Intent +- outcome: Let every graph reader distinguish authored intent, human bindings, and machine-derived structure. + +## Model +- **declared** — Human intent explicitly authored in a Spec or Pack; it is authoritative intent. +- **anchored** — A human binding from a code, test, or oracle location to one Spec ID; it is authoritative binding and carries no intent. +- **inferred** — Machine-derived structural information; it is advisory and never authoritative. +- **claim inheritance** — An edge computed from an authored source retains that source's declared claim; derivation is a mechanism, not a fourth claim. +- **delivery fact** — A realization signal computed from resolving edges, never an authored claim or edge. diff --git a/specs/extraction/derive-graph.sdp.md b/specs/extraction/derive-graph.sdp.md index 4fe5d4b..b483d9c 100644 --- a/specs/extraction/derive-graph.sdp.md +++ b/specs/extraction/derive-graph.sdp.md @@ -9,8 +9,16 @@ relations: --- # Carrier reification derives the one graph +The graph is the current projection of the repository at a commit. Git holds lifecycle history, so +removed records disappear from the current graph and a current `supersedes` relation is the only +forward pointer between records that still exist. + ## Intent - outcome: Expose one carrier-neutral derivation seam. ## Behavior - rule: Carrier reification feeds deriveGraph once; no consumer creates a second graph. +- rule: The graph is flat arrays of typed nodes and edges; hierarchy and containment are expressed by edges rather than nested nodes. +- rule: Declared relations resolve Primitive to Primitive, while `satisfies` and test `verifies` edges derive from anchors and run from their binding node to the direct Spec target. +- rule: Delivery facts are computed node facts: a resolving `satisfies` edge contributes `implemented`, and an enabled direct verifier contributes `has-verifier` only to its target. +- rule: Inferred structural edges are advisory inputs to impact analysis and never become authoritative graph truth. diff --git a/specs/extraction/determinism.sdp.md b/specs/extraction/determinism.sdp.md index ff12569..9af780f 100644 --- a/specs/extraction/determinism.sdp.md +++ b/specs/extraction/determinism.sdp.md @@ -16,3 +16,8 @@ relations: - statement: Two clean derivations of the same committed source produce byte-identical generated trees. - target: sha256(tree@run1) == sha256(tree@run2) - measurableBy: test/cli.test.ts clean-repo determinism + +## Behavior +- rule: Nodes sort by ID, edges sort by from, type, and to, and semantically compared output excludes wall-clock timestamps and run-specific hashes. +- rule: `sdp build --check-clean` repeats extraction and contract generation independently, failing on any graph or generated-contract byte divergence. +- rule: Static envelope fields fail extraction when they cannot be reified; optional TypeScript section detail may warn and drop, while Markdown documents refuse as a whole. diff --git a/specs/extraction/excludes.sdp.md b/specs/extraction/excludes.sdp.md new file mode 100644 index 0000000..83f35fc --- /dev/null +++ b/specs/extraction/excludes.sdp.md @@ -0,0 +1,18 @@ +--- +id: spec:extraction.excludes +kind: rule +altitude: feature +readiness: defined +relations: + refines: spec:extraction.derive-graph + decidedBy: spec:decisions.exclusion-contract +--- +# Extraction exclusions are strict consumer input + +## Intent +- outcome: Keep consumer-selected omissions precise without changing the extractor's canonical discovery rules. + +## Rule +- An exclusion is a unique, exact root-relative POSIX path prefix applied to both declared-carrier and anchor-candidate discovery surfaces. +- A prefix excludes itself and slash-delimited descendants only; it never excludes a merely similar sibling path. +- Empty, dot-relative, absolute, Windows-drive, backslash, trailing-slash, and parent-traversal paths are refused rather than normalized into a different meaning. diff --git a/specs/extraction/executable-contracts.sdp.md b/specs/extraction/executable-contracts.sdp.md new file mode 100644 index 0000000..3619371 --- /dev/null +++ b/specs/extraction/executable-contracts.sdp.md @@ -0,0 +1,16 @@ +--- +id: spec:extraction.executable-contracts +kind: behavior +altitude: feature +readiness: defined +relations: + refines: spec:extraction.build-pipeline +--- +# The build derives executable contracts from graph examples + +## Intent +- outcome: Give bound tests typed step and example-space contracts without reading authored Specs directly. + +## Behavior +- rule: `generateContracts` derives per-example step contracts and per-parent space contracts solely from the extracted graph. +- rule: A generated contract is disposable, keyed by Spec ID, and becomes unavailable when its authored example cannot bind honestly to its shared vocabulary. diff --git a/specs/extraction/regenerability.sdp.md b/specs/extraction/regenerability.sdp.md new file mode 100644 index 0000000..fd7c669 --- /dev/null +++ b/specs/extraction/regenerability.sdp.md @@ -0,0 +1,19 @@ +--- +id: spec:extraction.regenerability +kind: rule +altitude: feature +readiness: defined +relations: + refines: spec:extraction.determinism +--- +# Generated artifacts are disposable projections + +## Intent +- outcome: Keep the repository canonical while allowing every graph and projection to be rebuilt safely. + +## Rule +- Generated artifacts are disposable: deleting them and rebuilding from the same committed repository produces the same bytes. +- Consumers read the graph or link to source locations recorded in it; they never re-parse source or keep a parallel model. +- The graph is a single JSON projection with in-memory query support; a graph database remains deferred until measured traversal pain establishes a real need. +- Measured evidence from the self-hosting corpus keeps full rebuilds comfortable below roughly 50 Specs. +- Measured evidence defers a graph database until the graph reaches roughly 10k+ nodes or traversal pain establishes a real need. diff --git a/specs/extraction/schema-versioning.sdp.md b/specs/extraction/schema-versioning.sdp.md new file mode 100644 index 0000000..2070aec --- /dev/null +++ b/specs/extraction/schema-versioning.sdp.md @@ -0,0 +1,16 @@ +--- +id: spec:extraction.schema-versioning +kind: rule +altitude: story +readiness: defined +relations: + refines: spec:extraction.derive-graph +--- +# The graph declares its schema version + +## Intent +- outcome: Let consumers identify the graph payload contract without premature migration machinery. + +## Rule +- Every graph declares its schemaVersion, and MVP consumers require that field to be present and readable. +- Envelope-stable, section-extensible growth is normally additive; SemVer negotiation and a migration command remain deferred until a consumer needs them. diff --git a/specs/model/anchors.sdp.md b/specs/model/anchors.sdp.md new file mode 100644 index 0000000..927ece4 --- /dev/null +++ b/specs/model/anchors.sdp.md @@ -0,0 +1,21 @@ +--- +id: spec:model.anchors +kind: model +altitude: feature +readiness: defined +relations: + refines: spec:model.core-model + decidedBy: spec:decisions.binding-not-liveness +--- +# Source anchors bind code without carrying intent + +## Intent +- outcome: Connect implementation, tests, and oracles to Specs while keeping authored intent centralized in the carrier. + +## Model +- **anchor** — A human-written source binding from one code location to one Spec ID, carrying identity, an optional label, and one target only. +- **code anchor** — An implementation-flavored binding that derives an anchored satisfies edge. +- **test anchor** — A binding that derives an anchored verifies edge from a test to its target Spec. +- **oracle anchor** — A binding that records an oracle's models target without deriving a delivery fact. +- **anchor-constant form** — The top-level const builder call that the MVP extractor reifies; decorator and JSDoc forms remain unextracted representations. +- **Protocol builder binding** — A builder import from the public Protocol package, or a relative import whose importer-relative resolution — including the TypeScript `.js`-to-`.ts` convention — canonicalizes to this package's `ids` or `model/code-anchor` module; consumer-local lookalike modules confer no binding authority. On the CommonJS package surface the trusted relative-module set is empty (`import.meta.url` is rewritten away), so relative bindings mint no anchors there while package imports stay trusted. diff --git a/specs/model/core-model.sdp.md b/specs/model/core-model.sdp.md new file mode 100644 index 0000000..88e17b5 --- /dev/null +++ b/specs/model/core-model.sdp.md @@ -0,0 +1,21 @@ +--- +id: spec:model.core-model +kind: model +altitude: feature +readiness: defined +relations: + refines: spec:protocol.self-hosting + decidedBy: spec:decisions.one-primitive +--- +# The Protocol models delivery with one enrichable Spec + +## Intent +- outcome: Give every authored delivery statement one stable shape and independent coordinates. + +## Model +- **Spec** — The one authored truth-primitive, enriched in place without changing artifact type. +- **envelope** — The stable outer shape of id, title, kind, altitude, readiness, and relations; sections carry extension detail. +- **kind** — The true subtype that categorizes a Spec's truth and changes its required detail and validation. +- **altitude** — The scope position `epic`, `feature`, or `story`. +- **readiness** — The author-stated design-maturity position `idea`, `scoped`, `defined`, or `ready`, checked against a structural floor. +- **delivery fact** — A derived realization signal such as implemented or has-verifier; it is never authored readiness. diff --git a/specs/model/pack-aggregate.sdp.md b/specs/model/pack-aggregate.sdp.md new file mode 100644 index 0000000..35e1bcd --- /dev/null +++ b/specs/model/pack-aggregate.sdp.md @@ -0,0 +1,20 @@ +--- +id: spec:model.pack-aggregate +kind: model +altitude: story +readiness: defined +relations: + refines: spec:model.core-model + decidedBy: spec:decisions.pack-reified +--- +# A Pack is a truth-free review aggregate + +## Intent +- outcome: Let reviewers group related Specs without introducing a second truth-bearing artifact. + +## Model +- **Pack** — An authored aggregate that groups related Specs for ideation and review while stating no system truth of its own. +- **framing** — A plain descriptive note explaining why a Pack exists; it is not Spec intent. +- **membership** — A declared manifest reference that derives a belongsTo edge; a Spec may belong to many Packs. +- **modelRefs** — References from a Pack to standalone model Specs that carry shared vocabulary. +- **refinement** — A truth-bearing parent-child relation, distinct from the cross-cutting Pack aggregate. diff --git a/specs/model/relations.sdp.md b/specs/model/relations.sdp.md new file mode 100644 index 0000000..34ba337 --- /dev/null +++ b/specs/model/relations.sdp.md @@ -0,0 +1,21 @@ +--- +id: spec:model.relations +kind: model +altitude: feature +readiness: defined +relations: + refines: spec:model.core-model +--- +# Specs declare typed directed relations + +## Intent +- outcome: Preserve the explicit intent links that make a delivery model navigable and queryable. + +## Model +- **authored relation** — A declared, directed Spec-to-Spec edge that records human intent. +- **refines** — A child points to its more precise parent. +- **dependsOn** — A dependent Spec points to the Spec it needs. +- **constrainedBy** — A bounded Spec points to its rule, constraint, or policy Spec. +- **decidedBy** — A shaped Spec points to its Decision Record. +- **verifies** — A verifier points to the Spec it verifies. +- **supersedes** — A current Decision Record points forward to the decision it replaces. diff --git a/specs/model/spec-sections.sdp.md b/specs/model/spec-sections.sdp.md new file mode 100644 index 0000000..ae6b055 --- /dev/null +++ b/specs/model/spec-sections.sdp.md @@ -0,0 +1,24 @@ +--- +id: spec:model.spec-sections +kind: model +altitude: feature +readiness: defined +relations: + refines: spec:model.core-model + decidedBy: + - spec:decisions.point-per-example + - spec:decisions.content-only-sections + - spec:decisions.typing-law +--- +# Spec sections carry typed detail and direct verifier semantics + +## Intent +- outcome: Extend Specs with local detail without weakening their envelope or confusing binding evidence with intent. + +## Model +- **section** — An optional detail slice of a Spec: intent, behavior, constraints, model, design, decision, verification, or ui. +- **typing law** — Every section read by a readiness-floor clause has a closed typed shape; unsettled design and ui surfaces remain open bags. +- **content-only section** — A section carries local content, while relations carry links to promoted standalone Specs. +- **promotion** — Moving shared or independently reviewed content into a standalone Spec of the matching kind, exclusively rather than alongside inline content. +- **verifies** — A direct verifier-to-target relation whose enabled test binding can derive has-verifier only for that stated target. +- **enabled verifier** — An example or direct test with a linked, resolvable test anchor; runner execution and pass state remain outside the graph. diff --git a/specs/model/stable-ids.sdp.md b/specs/model/stable-ids.sdp.md new file mode 100644 index 0000000..f035291 --- /dev/null +++ b/specs/model/stable-ids.sdp.md @@ -0,0 +1,17 @@ +--- +id: spec:model.stable-ids +kind: rule +altitude: story +readiness: defined +relations: + refines: spec:model.core-model +--- +# Stable IDs are the Protocol's durable join key + +## Intent +- outcome: Keep intent, bindings, and graph nodes connected through names that survive code refactoring. + +## Rule +- A Protocol ID is stable, unique, namespaced, human-readable, and the only binding between intent and code. +- An ID uses a lowercase namespace and dotted path, with an optional single `#` sub-part; referential-integrity checks reject malformed or unresolved references. +- IDs carry no history: a rename is a repository edit recorded by git rather than graph-resident bookkeeping. diff --git a/specs/protocol/self-hosting.sdp.md b/specs/protocol/self-hosting.sdp.md index e3a91e9..f64bbc2 100644 --- a/specs/protocol/self-hosting.sdp.md +++ b/specs/protocol/self-hosting.sdp.md @@ -7,7 +7,11 @@ relations: dependsOn: - spec:carrier.markdown-authoring - spec:model.protocol-domain - decidedBy: spec:decisions.concept-docs-dissolve + decidedBy: + - spec:decisions.concept-docs-dissolve + - spec:decisions.executable-meta-model + - spec:decisions.adopt-the-nouns + - spec:decisions.protocol-naming --- # The Protocol authors and validates itself diff --git a/specs/self-hosting.pack.sdp.ts b/specs/self-hosting.pack.sdp.ts index 94177de..505ee1e 100644 --- a/specs/self-hosting.pack.sdp.ts +++ b/specs/self-hosting.pack.sdp.ts @@ -2,24 +2,67 @@ import { pack, packId, ref } from "@libar-dev/software-delivery-protocol"; export const selfHostingV1Pack = pack({ id: packId("pack:self-hosting-v1"), - title: "Self-hosting phase 1", - framing: "The Protocol authors and validates its own phase-1 delivery model.", + title: "Self-hosting", + framing: "The Protocol authors and validates its own delivery model.", specs: [ ref("spec:carrier.markdown-authoring"), ref("spec:carrier.envelope-contract"), ref("spec:carrier.markdown-parser"), ref("spec:carrier.sdp-import"), + ref("spec:carrier.sdp-import.round-trip"), ref("spec:carrier.prose-ownership-rule"), ref("spec:protocol.self-hosting"), ref("spec:extraction.derive-graph"), ref("spec:extraction.determinism"), ref("spec:extraction.build-pipeline"), + ref("spec:extraction.excludes"), + ref("spec:extraction.claim-taxonomy"), + ref("spec:extraction.regenerability"), + ref("spec:extraction.schema-versioning"), + ref("spec:extraction.executable-contracts"), ref("spec:validation.readiness-floor"), ref("spec:validation.duplicate-ids"), + ref("spec:validation.two-check-families"), + ref("spec:validation.referential-integrity"), + ref("spec:validation.claim-separation"), + ref("spec:validation.verification-linkage"), + ref("spec:validation.pack-coherence"), + ref("spec:validation.authored-honesty"), + ref("spec:validation.warn-level-signals"), + ref("spec:consumers.projections-model"), + ref("spec:consumers.agent-surface"), + ref("spec:consumers.design-review"), + ref("spec:consumers.reader"), + ref("spec:consumers.edit-model"), ref("spec:model.protocol-domain"), + ref("spec:model.core-model"), + ref("spec:model.spec-sections"), + ref("spec:model.relations"), + ref("spec:model.stable-ids"), + ref("spec:model.pack-aggregate"), + ref("spec:model.anchors"), ref("spec:validation.duplicate-ids.dual-carrier"), ref("spec:decisions.plain-language-references"), ref("spec:decisions.concept-docs-dissolve"), + ref("spec:decisions.one-validation-path"), + ref("spec:decisions.sdp-ts-extension"), + ref("spec:decisions.point-per-example"), + ref("spec:decisions.carrier-ruling"), + ref("spec:decisions.prose-ownership"), + ref("spec:decisions.envelope-grammar-posture"), + ref("spec:decisions.exclusion-contract"), + ref("spec:decisions.executable-meta-model"), + ref("spec:decisions.adopt-the-nouns"), + ref("spec:decisions.one-primitive"), + ref("spec:decisions.protocol-naming"), + ref("spec:decisions.binding-not-liveness"), + ref("spec:decisions.content-only-sections"), + ref("spec:decisions.typing-law"), + ref("spec:decisions.kind-conditional-floor"), + ref("spec:decisions.carried-evidence"), + ref("spec:decisions.pack-reified"), + ref("spec:decisions.agent-surface-scripts-graph"), + ref("spec:decisions.mcp-deferred"), ], - modelRefs: [ref("spec:model.protocol-domain")], + modelRefs: [ref("spec:model.protocol-domain"), ref("spec:model.core-model")], }); diff --git a/specs/validation/authored-honesty.sdp.md b/specs/validation/authored-honesty.sdp.md new file mode 100644 index 0000000..e5fcc50 --- /dev/null +++ b/specs/validation/authored-honesty.sdp.md @@ -0,0 +1,16 @@ +--- +id: spec:validation.authored-honesty +kind: rule +altitude: feature +readiness: defined +relations: + refines: spec:validation.two-check-families +--- +# Machine truth is never authored + +## Intent +- outcome: Keep derived graph truth trustworthy by rejecting any authored substitute for machine-derived claims or facts. + +## Rule +- Specs and Packs must not author derived edges, claims, or delivery facts, and any stated delivery facts must equal the graph's recomputed facts. +- The realizing validator entrypoints are `checkAuthoringShape` and `checkDeliveryFacts` in `src/validate/validators.ts`. diff --git a/specs/validation/claim-separation.sdp.md b/specs/validation/claim-separation.sdp.md new file mode 100644 index 0000000..b5c7053 --- /dev/null +++ b/specs/validation/claim-separation.sdp.md @@ -0,0 +1,16 @@ +--- +id: spec:validation.claim-separation +kind: rule +altitude: story +readiness: defined +relations: + refines: spec:validation.two-check-families +--- +# Graph claims and contracts stay distinct + +## Intent +- outcome: Preserve the graph's declared, anchored, and inferred distinctions while keeping its typed contracts lawful. + +## Rule +- Node and edge types, claims, descriptors, and relation endpoint contracts must use their ratified forms; the claim taxonomy never collapses. +- The realizing validator entrypoint is `checkClaimSeparation` in `src/validate/validators.ts`. diff --git a/specs/validation/pack-coherence.sdp.md b/specs/validation/pack-coherence.sdp.md new file mode 100644 index 0000000..d4d0ab3 --- /dev/null +++ b/specs/validation/pack-coherence.sdp.md @@ -0,0 +1,16 @@ +--- +id: spec:validation.pack-coherence +kind: rule +altitude: story +readiness: defined +relations: + refines: spec:validation.two-check-families +--- +# Packs are coherent aggregates + +## Intent +- outcome: Keep review aggregates coherent without treating them as truth-bearing delivery artifacts. + +## Rule +- Pack membership must not repeat a Spec, and every modelRef must resolve to a model-kind Spec. +- The realizing validator entrypoint is `checkPackCoherence` in `src/validate/validators.ts`. diff --git a/specs/validation/readiness-floor.sdp.md b/specs/validation/readiness-floor.sdp.md index aa98cfe..3f0ec6d 100644 --- a/specs/validation/readiness-floor.sdp.md +++ b/specs/validation/readiness-floor.sdp.md @@ -6,6 +6,9 @@ readiness: ready relations: refines: spec:protocol.self-hosting dependsOn: spec:model.protocol-domain + decidedBy: + - spec:decisions.kind-conditional-floor + - spec:decisions.carried-evidence --- # Stated readiness must clear its floor diff --git a/specs/validation/referential-integrity.sdp.md b/specs/validation/referential-integrity.sdp.md new file mode 100644 index 0000000..2e3535c --- /dev/null +++ b/specs/validation/referential-integrity.sdp.md @@ -0,0 +1,16 @@ +--- +id: spec:validation.referential-integrity +kind: rule +altitude: story +readiness: defined +relations: + refines: spec:validation.two-check-families +--- +# Every graph reference resolves + +## Intent +- outcome: Keep derived graph relationships trustworthy by refusing references to absent nodes. + +## Rule +- Every edge endpoint and every Pack model reference must resolve to a node in the derived graph; an unresolved reference is a conformance error. +- The realizing validator entrypoint is `checkReferentialIntegrity` in `src/validate/validators.ts`. diff --git a/specs/validation/two-check-families.sdp.md b/specs/validation/two-check-families.sdp.md new file mode 100644 index 0000000..df234c3 --- /dev/null +++ b/specs/validation/two-check-families.sdp.md @@ -0,0 +1,19 @@ +--- +id: spec:validation.two-check-families +kind: rule +altitude: feature +readiness: defined +relations: + refines: spec:protocol.self-hosting + decidedBy: spec:decisions.one-validation-path +--- +# Validation separates well-formedness from non-pretending + +## Intent +- outcome: Keep the graph trustworthy by checking conformance and honesty without judging content quality or enforcing workflow. + +## Rule +- Every validator belongs to either the conformance family, which checks meta-model well-formedness, or the honesty family, which rejects authored or overstated derived truth. +- Validation errors fail the build; gaps and orphans remain informative signals rather than delivery-process gates. +- Types enforce structural shape, schema validates graph payloads, and graph validators enforce cross-file conformance and honesty; no one layer substitutes for the others. +- All graph validation runs through the one derived graph path: source, extraction, graph, then checks. diff --git a/specs/validation/verification-linkage.sdp.md b/specs/validation/verification-linkage.sdp.md new file mode 100644 index 0000000..048f066 --- /dev/null +++ b/specs/validation/verification-linkage.sdp.md @@ -0,0 +1,16 @@ +--- +id: spec:validation.verification-linkage +kind: rule +altitude: feature +readiness: defined +relations: + refines: spec:validation.two-check-families +--- +# Declared verification resolves to a performing trace + +## Intent +- outcome: Keep verification relationships meaningful by requiring declared test and oracle traces to resolve to their enabled bindings. + +## Rule +- A declared verifies relation and an oracle model relation must resolve through their respective binding traces before either can stand as verification evidence. +- The realizing validator entrypoints are `checkVerifiesLinkage` and `checkOracleLinkage` in `src/validate/validators.ts`. diff --git a/specs/validation/warn-level-signals.sdp.md b/specs/validation/warn-level-signals.sdp.md new file mode 100644 index 0000000..2c24952 --- /dev/null +++ b/specs/validation/warn-level-signals.sdp.md @@ -0,0 +1,16 @@ +--- +id: spec:validation.warn-level-signals +kind: rule +altitude: feature +readiness: defined +relations: + refines: spec:validation.two-check-families +--- +# Missing connective evidence warns without failing + +## Intent +- outcome: Surface graph conditions that need attention without turning informative delivery signals into workflow gates. + +## Rule +- Orphaned Specs and ready Specs lacking a resolving verifier are warnings, not validation errors. +- The realizing validator entrypoints are `checkOrphans` and `checkGaps` in `src/validate/validators.ts`. diff --git a/src/cli/artifacts.ts b/src/cli/artifacts.ts new file mode 100644 index 0000000..3f69723 --- /dev/null +++ b/src/cli/artifacts.ts @@ -0,0 +1,33 @@ +import { rmSync } from "node:fs"; + +import type { CliOutput } from "./output.js"; +import { errorMessage, writeStderr } from "./output.js"; + +export function removeArtifact(path: string, rm: typeof rmSync): string | undefined { + try { + rm(path, { recursive: true, force: true }); + return undefined; + } catch (error) { + const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined; + + return code === "ENOENT" || code === "ENOTDIR" ? undefined : errorMessage(error); + } +} + +export function removeArtifacts( + paths: readonly string[], + output: CliOutput, + command: string, + rm: typeof rmSync, +): void { + for (const path of paths) { + const failure = removeArtifact(path, rm); + + if (failure !== undefined) { + writeStderr( + output, + `sdp ${command}: stale ${path} could not be removed (${failure}) — do not read it as current.\n`, + ); + } + } +} diff --git a/src/cli/build-args.ts b/src/cli/build-args.ts new file mode 100644 index 0000000..165eb79 --- /dev/null +++ b/src/cli/build-args.ts @@ -0,0 +1,95 @@ +import { statSync } from "node:fs"; +import { resolve } from "node:path"; + +import { InvalidExcludePathError, normalizeExcludes } from "../extract/discover.js"; +import type { CliOutput } from "./output.js"; +import { writeStderr } from "./output.js"; + +export interface BuildArgs { + readonly root: string; + readonly exclude: readonly string[]; + readonly checkClean: boolean; +} + +export function parseBuildArgs( + args: readonly string[], + output: CliOutput, + command: string, +): BuildArgs | undefined { + let root: string | undefined; + let checkClean = false; + const rawExcludes: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + + if (argument === undefined) { + continue; + } + + if (argument === "--check-clean") { + checkClean = true; + continue; + } + + if (argument === "--exclude") { + const exclude = args[index + 1]; + + if (exclude === undefined) { + writeStderr(output, `sdp ${command}: --exclude requires a path.\n`); + return undefined; + } + + if (exclude.startsWith("--")) { + writeStderr(output, `sdp ${command}: --exclude expects a path, got ${exclude}\n`); + return undefined; + } + + rawExcludes.push(exclude); + index += 1; + continue; + } + + if (argument.startsWith("--")) { + writeStderr(output, `sdp ${command}: unknown option ${argument}\n`); + return undefined; + } + + if (root !== undefined) { + writeStderr(output, `sdp ${command} takes at most one root argument.\n`); + return undefined; + } + + root = argument; + } + + let exclude: readonly string[]; + + try { + exclude = normalizeExcludes(rawExcludes); + } catch (error) { + if (error instanceof InvalidExcludePathError) { + writeStderr(output, `sdp ${command}: invalid --exclude path "${error.path}"\n`); + return undefined; + } + + throw error; + } + + const resolvedRoot = resolve(process.cwd(), root ?? "."); + + if (!isDirectory(resolvedRoot)) { + writeStderr(output, `sdp ${command}: root "${resolvedRoot}" is not a directory.\n`); + return undefined; + } + + return { root: resolvedRoot, exclude, checkClean }; +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} diff --git a/src/cli/build-command.ts b/src/cli/build-command.ts new file mode 100644 index 0000000..182fe56 --- /dev/null +++ b/src/cli/build-command.ts @@ -0,0 +1,182 @@ +import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { generateContracts } from "../codegen/contracts.js"; +import { extract } from "../extract/index.js"; +import { serializeGraph } from "../extract/serialize.js"; +import type { GraphSchema } from "../graph/schema.js"; +import { codeAnchorId, ref } from "../ids.js"; +import { codeAnchor } from "../model/code-anchor.js"; +import { removeArtifact, removeArtifacts } from "./artifacts.js"; +import type { BuildArgs } from "./build-args.js"; +import type { CliOutput } from "./output.js"; +import { errorMessage, formatFinding, writeStderr, writeStdout } from "./output.js"; + +export interface BuildHooks { + readonly extract?: typeof extract; + readonly generateContracts?: typeof generateContracts; + readonly rmSync?: typeof rmSync; +} + +export interface BuildOutcome { + readonly exitCode: number; + readonly graph?: GraphSchema; +} + +function contractFilesEqual( + first: ReadonlyMap, + second: ReadonlyMap, +): boolean { + if (first.size !== second.size) { + return false; + } + + for (const [path, content] of first) { + if (second.get(path) !== content) { + return false; + } + } + + return true; +} + +const regenerabilityAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.regenerability"), + label: "repeats graph and contract producers for deterministic regeneration", + satisfies: ref("spec:extraction.regenerability"), +}); +void regenerabilityAnchor; + +export function runBuild( + parsed: BuildArgs, + output: CliOutput, + command: string, + hooks: BuildHooks, +): BuildOutcome { + const { root: resolvedRoot, exclude, checkClean } = parsed; + const extractionOptions = { root: resolvedRoot, exclude }; + const runExtract = hooks.extract ?? extract; + const runGenerateContracts = hooks.generateContracts ?? generateContracts; + const recoveryRm = hooks.rmSync ?? rmSync; + const graphPath = join(resolvedRoot, "generated", "graph.json"); + const contractsPath = join(resolvedRoot, "generated", "contracts"); + const viewPath = join(resolvedRoot, "generated", "design-review"); + + const failBuild = (message: string): BuildOutcome => { + writeStderr(output, message); + removeArtifacts( + [graphPath, `${graphPath}.tmp`, contractsPath, `${contractsPath}.tmp`], + output, + command, + recoveryRm, + ); + return { exitCode: 1 }; + }; + + for (const staleViewPath of [viewPath, `${viewPath}.tmp`]) { + const failure = removeArtifact(staleViewPath, recoveryRm); + + if (failure !== undefined) { + return failBuild( + `sdp ${command}: stale ${staleViewPath} could not be removed (${failure}) — build stopped so it cannot read as current.\n`, + ); + } + } + + try { + const result = runExtract(extractionOptions); + const findings = result.report.findings; + + for (const finding of findings) { + writeStderr(output, formatFinding(finding)); + } + + if ( + result.counts.specs === 0 && + !findings.some( + (finding) => + finding.file?.endsWith(".sdp.ts") === true || finding.file?.endsWith(".sdp.md") === true, + ) + ) { + writeStderr( + output, + `note: no *.sdp.ts or *.sdp.md spec files found under ${resolvedRoot} — the authored model is empty. Is this the right extraction root?\n`, + ); + } + + const extractionErrorCount = findings.filter((finding) => finding.severity === "error").length; + + if (extractionErrorCount > 0) { + const warningCount = findings.length - extractionErrorCount; + writeStdout( + output, + `${String(result.counts.specs)} specs · ${String(result.counts.packs)} packs · ${String(result.counts.anchors)} anchors → ${String(result.graph.nodes.length)} nodes · ${String(result.graph.edges.length)} edges (${String(extractionErrorCount)} errors, ${String(warningCount)} warnings)\n`, + ); + return failBuild(`sdp ${command}: hard errors present — graph.json not written.\n`); + } + + const serialized = serializeGraph(result.graph); + const contracts = runGenerateContracts(result.graph); + + for (const finding of contracts.findings) { + writeStderr(output, formatFinding(finding)); + } + + const allFindings = [...findings, ...contracts.findings]; + const errorCount = allFindings.filter((finding) => finding.severity === "error").length; + const warningCount = allFindings.length - errorCount; + const summary = `${String(result.counts.specs)} specs · ${String(result.counts.packs)} packs · ${String(result.counts.anchors)} anchors → ${String(result.graph.nodes.length)} nodes · ${String(result.graph.edges.length)} edges (${String(errorCount)} errors, ${String(warningCount)} warnings)\n`; + + if (checkClean) { + const secondResult = runExtract(extractionOptions); + const second = serializeGraph(secondResult.graph); + + if (second !== serialized) { + return failBuild( + `sdp ${command} --check-clean: two independent extractions diverged — the build is not deterministic.\n`, + ); + } + + const secondContracts = runGenerateContracts(secondResult.graph); + + if (!contractFilesEqual(contracts.files, secondContracts.files)) { + return failBuild( + `sdp ${command} --check-clean: two independent contract generations diverged — the build is not deterministic.\n`, + ); + } + } + + const temporaryPath = `${graphPath}.tmp`; + mkdirSync(join(resolvedRoot, "generated"), { recursive: true }); + writeFileSync(temporaryPath, serialized, "utf8"); + renameSync(temporaryPath, graphPath); + + const contractsTemporaryPath = `${contractsPath}.tmp`; + rmSync(contractsTemporaryPath, { recursive: true, force: true }); + + if (contracts.files.size > 0) { + mkdirSync(contractsTemporaryPath, { recursive: true }); + + for (const [relativePath, content] of contracts.files) { + writeFileSync(join(contractsTemporaryPath, relativePath), content, "utf8"); + } + + rmSync(contractsPath, { recursive: true, force: true }); + renameSync(contractsTemporaryPath, contractsPath); + } else { + rmSync(contractsPath, { recursive: true, force: true }); + } + + writeStdout(output, summary); + writeStdout(output, `Wrote ${graphPath}\n`); + + if (contracts.files.size > 0) { + writeStdout(output, `Wrote ${contractsPath} (${String(contracts.files.size)} modules)\n`); + } + + return { exitCode: 0, graph: result.graph }; + } catch (error) { + const detail = error instanceof Error ? error.message : errorMessage(error); + return failBuild(`sdp ${command}: ${detail}\n`); + } +} diff --git a/src/cli/import-command.ts b/src/cli/import-command.ts new file mode 100644 index 0000000..5b9d520 --- /dev/null +++ b/src/cli/import-command.ts @@ -0,0 +1,137 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { importFindingIds, importTypeScriptSpec } from "../index.js"; +import type { Finding } from "../validate/contracts.js"; +import { publishImports } from "./import-publish.js"; +import type { ImportPublicationHooks, PlannedImport } from "./import-publish.js"; +import { scanImportPaths } from "./import-scan.js"; +import type { ImportScanHooks } from "./import-scan.js"; +import type { CliOutput } from "./output.js"; +import { errorMessage, formatFinding, writeStderr, writeStdout } from "./output.js"; + +export interface ImportHooks extends ImportPublicationHooks, ImportScanHooks { + readonly readFileSync?: typeof readFileSync; + readonly existsSync?: typeof existsSync; + readonly importTypeScriptSpec?: typeof importTypeScriptSpec; +} + +export interface ImportArgs { + readonly paths: readonly string[]; + readonly dryRun: boolean; +} + +export function parseImportArgs( + args: readonly string[], + output: CliOutput, +): ImportArgs | undefined { + const paths: string[] = []; + let dryRun = false; + let operandsOnly = false; + + for (const argument of args) { + if (!operandsOnly && argument === "--") { + operandsOnly = true; + continue; + } + + if (!operandsOnly && argument === "--dry-run") { + dryRun = true; + continue; + } + + if (!operandsOnly && argument.startsWith("--")) { + writeStderr(output, `sdp import: unknown option ${argument}\n`); + return undefined; + } + + paths.push(resolve(process.cwd(), argument)); + } + + if (paths.length === 0) { + writeStderr(output, "sdp import: requires at least one path.\n"); + return undefined; + } + + return { paths, dryRun }; +} + +function targetExistsFinding(targetPath: string): Finding { + return { + validatorId: importFindingIds.targetExists, + family: "conformance", + severity: "error", + message: "the Markdown target already exists and will not be overwritten", + file: targetPath, + }; +} + +export function runImport(parsed: ImportArgs, output: CliOutput, hooks: ImportHooks = {}): number { + const read = hooks.readFileSync ?? readFileSync; + const targetExists = hooks.existsSync ?? existsSync; + const runImportTypeScriptSpec = hooks.importTypeScriptSpec ?? importTypeScriptSpec; + const scan = scanImportPaths(parsed.paths, hooks); + const planned: PlannedImport[] = []; + let failed = + scan.findings.some((finding) => finding.severity === "error") || + scan.operationalFailures.length > 0; + + for (const finding of scan.findings) { + writeStderr(output, formatFinding(finding)); + } + + for (const failure of scan.operationalFailures) { + writeStderr(output, `sdp import: ${failure}\n`); + } + + for (const sourcePath of scan.sourcePaths) { + try { + const result = runImportTypeScriptSpec(read(sourcePath, "utf8"), sourcePath); + const findings = [...result.findings]; + const targetPath = result.emitted?.path; + + if (targetPath !== undefined && targetExists(targetPath)) { + findings.push(targetExistsFinding(targetPath)); + } + + for (const finding of findings) { + writeStderr(output, formatFinding(finding)); + } + + if ( + findings.some((finding) => finding.severity === "error") || + result.emitted === undefined || + targetPath === undefined + ) { + failed = true; + continue; + } + + planned.push({ sourcePath, targetPath, content: result.emitted.content }); + } catch (error) { + failed = true; + const detail = error instanceof Error ? error.message : errorMessage(error); + writeStderr(output, `sdp import: ${sourcePath} — ${detail}\n`); + } + } + + if (failed || planned.length === 0) { + return 1; + } + + if (parsed.dryRun) { + for (const entry of planned) { + writeStdout(output, `=== ${entry.targetPath} ===\n${entry.content}`); + } + return 0; + } + + if (!publishImports(planned, output, hooks)) { + return 1; + } + + for (const entry of planned) { + writeStdout(output, `Wrote ${entry.targetPath}\n`); + } + return 0; +} diff --git a/src/cli/import-publish.ts b/src/cli/import-publish.ts new file mode 100644 index 0000000..80929ed --- /dev/null +++ b/src/cli/import-publish.ts @@ -0,0 +1,70 @@ +import { linkSync, rmSync, writeFileSync } from "node:fs"; + +import { removeArtifact, removeArtifacts } from "./artifacts.js"; +import type { CliOutput } from "./output.js"; +import { errorMessage, writeStderr } from "./output.js"; + +export interface ImportPublicationHooks { + readonly writeFileSync?: typeof writeFileSync; + readonly linkSync?: typeof linkSync; + readonly rmSync?: typeof rmSync; +} + +export interface PlannedImport { + readonly sourcePath: string; + readonly targetPath: string; + readonly content: string; +} + +interface PreparedImport extends PlannedImport { + readonly temporaryPath: string; +} + +export function publishImports( + planned: readonly PlannedImport[], + output: CliOutput, + hooks: ImportPublicationHooks, +): boolean { + const write = hooks.writeFileSync ?? writeFileSync; + const link = hooks.linkSync ?? linkSync; + const remove = hooks.rmSync ?? rmSync; + const prepared: PreparedImport[] = planned.map((entry, index) => ({ + ...entry, + temporaryPath: `${entry.targetPath}.sdp-import-${String(process.pid)}-${String(index)}.tmp`, + })); + const createdTemporaryPaths: string[] = []; + const publishedPaths: string[] = []; + + try { + for (const entry of prepared) { + write(entry.temporaryPath, entry.content, { encoding: "utf8", flag: "wx" }); + createdTemporaryPaths.push(entry.temporaryPath); + } + + for (const entry of prepared) { + try { + link(entry.temporaryPath, entry.targetPath); + } catch (error) { + throw new Error( + `publication requires a filesystem with hard-link support (${errorMessage(error)})`, + ); + } + publishedPaths.push(entry.targetPath); + const failure = removeArtifact(entry.temporaryPath, remove); + + if (failure !== undefined) { + writeStderr( + output, + `sdp import: stale ${entry.temporaryPath} could not be removed (${failure}) — do not read it as current.\n`, + ); + } + } + + return true; + } catch (error) { + const detail = error instanceof Error ? error.message : errorMessage(error); + writeStderr(output, `sdp import: ${detail}\n`); + removeArtifacts([...createdTemporaryPaths, ...publishedPaths], output, "import", remove); + return false; + } +} diff --git a/src/cli/import-scan.ts b/src/cli/import-scan.ts new file mode 100644 index 0000000..ef669e4 --- /dev/null +++ b/src/cli/import-scan.ts @@ -0,0 +1,137 @@ +import { realpathSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { isExcludedDiscoveryDirectory } from "../extract/discover.js"; +import { importFindingIds } from "../import/import.js"; +import type { Finding } from "../validate/contracts.js"; +import { errorMessage } from "./output.js"; + +export interface ImportScanResult { + readonly sourcePaths: readonly string[]; + readonly findings: readonly Finding[]; + readonly operationalFailures: readonly string[]; +} + +export interface ImportScanHooks { + readonly realpathSync?: (path: string) => string; +} + +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function invalidSourceFinding(path: string): Finding { + return { + validatorId: importFindingIds.invalidSourcePath, + family: "conformance", + severity: "error", + message: "the explicitly requested import file must end with .sdp.ts", + file: path, + }; +} + +function noSourcesFinding(path: string): Finding { + return { + validatorId: importFindingIds.noSources, + family: "conformance", + severity: "warning", + message: "the requested import path contains no .sdp.ts carrier", + file: path, + }; +} + +function collectDirectorySources(directory: string, collected: string[]): void { + const entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) => + compareCodeUnits(left.name, right.name), + ); + + for (const entry of entries) { + if (entry.isDirectory()) { + if (!isExcludedDiscoveryDirectory(entry.name)) { + collectDirectorySources(join(directory, entry.name), collected); + } + continue; + } + + if (entry.isFile() && entry.name.endsWith(".sdp.ts")) { + collected.push(join(directory, entry.name)); + } + } +} + +export function scanImportPaths( + paths: readonly string[], + hooks: ImportScanHooks = {}, +): ImportScanResult { + const canonicalize = hooks.realpathSync ?? realpathSync; + const sourcePaths: string[] = []; + const findings: Finding[] = []; + const operationalFailures: string[] = []; + + for (const path of paths) { + try { + const entry = statSync(path); + + if (entry.isFile()) { + if (path.endsWith(".sdp.ts")) { + sourcePaths.push(path); + } else { + findings.push(invalidSourceFinding(path)); + } + continue; + } + + if (!entry.isDirectory()) { + findings.push(invalidSourceFinding(path)); + continue; + } + + const before = sourcePaths.length; + collectDirectorySources(path, sourcePaths); + + if (sourcePaths.length === before) { + findings.push(noSourcesFinding(path)); + } + } catch (error) { + const detail = error instanceof Error ? error.message : errorMessage(error); + operationalFailures.push(`${path} — ${detail}`); + } + } + + const canonicalSources = new Map(); + + for (const sourcePath of sourcePaths) { + let stats: ReturnType; + + try { + stats = statSync(sourcePath); + } catch (error) { + const detail = error instanceof Error ? error.message : errorMessage(error); + operationalFailures.push(`${sourcePath} — ${detail}`); + continue; + } + + let canonical: string; + + try { + canonical = canonicalize(sourcePath); + } catch (error) { + const detail = error instanceof Error ? error.message : errorMessage(error); + operationalFailures.push(`${sourcePath} — ${detail}`); + continue; + } + + const identity = + stats.ino === 0 ? `path:${canonical}` : `dev:${String(stats.dev)}:ino:${String(stats.ino)}`; + + if (!canonicalSources.has(identity)) { + canonicalSources.set(identity, canonical); + } + } + + return { + sourcePaths: [...canonicalSources.values()].sort(compareCodeUnits), + findings, + operationalFailures, + }; +} diff --git a/src/cli/output.ts b/src/cli/output.ts new file mode 100644 index 0000000..56fe974 --- /dev/null +++ b/src/cli/output.ts @@ -0,0 +1,40 @@ +import type { Finding } from "../validate/contracts.js"; + +export interface CliOutput { + readonly stdout?: { readonly write: (chunk: string) => void }; + readonly stderr?: { readonly write: (chunk: string) => void }; +} + +export interface RenderedFinding { + readonly validatorId: string; + readonly severity: string; + readonly message: string; + readonly file?: string; + readonly line?: number; +} + +export const defaultCliOutput: CliOutput = { + stdout: process.stdout, + stderr: process.stderr, +}; + +export function writeStdout(output: CliOutput, text: string): void { + output.stdout?.write(text); +} + +export function writeStderr(output: CliOutput, text: string): void { + output.stderr?.write(text); +} + +export function formatFinding(finding: Finding | RenderedFinding): string { + const location = + finding.file === undefined + ? "" + : `${finding.file}${finding.line === undefined ? "" : `:${String(finding.line)}`} — `; + + return `${location}[${finding.severity}] ${finding.validatorId} — ${finding.message}\n`; +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/cli/sdp.ts b/src/cli/sdp.ts index 3816f2f..bdabeaa 100644 --- a/src/cli/sdp.ts +++ b/src/cli/sdp.ts @@ -1,18 +1,16 @@ #!/usr/bin/env node -import { mkdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { generateContracts } from "../codegen/contracts.js"; -import { normalizeExcludes } from "../extract/discover.js"; -import { extract } from "../extract/index.js"; -import { serializeGraph } from "../extract/serialize.js"; -import type { GraphSchema } from "../graph/schema.js"; -import { renderDesignReview } from "../projections/design-review.js"; -import { createReader } from "../reader/reader.js"; -import type { Finding } from "../validate/contracts.js"; -import { validateGraph } from "../validate/validators.js"; +import { parseBuildArgs } from "./build-args.js"; +import { runBuild } from "./build-command.js"; +import { parseImportArgs, runImport } from "./import-command.js"; +import type { ImportHooks } from "./import-command.js"; +import { defaultCliOutput, writeStderr, writeStdout } from "./output.js"; +import type { CliOutput } from "./output.js"; +import { runValidate, runView } from "./validate-view-command.js"; +import type { ValidationViewHooks } from "./validate-view-command.js"; export const SDP_HELP_TEXT = `sdp — Libar Software Delivery Protocol Usage: @@ -20,6 +18,7 @@ Usage: sdp build [root] [--exclude PATH]... [--check-clean] sdp validate [root] [--exclude PATH]... [--check-clean] sdp view [root] [--exclude PATH]... [--check-clean] + sdp import [--dry-run] Commands: build Extract every *.sdp.ts and *.sdp.md under root (default: cwd), plus the anchor @@ -39,476 +38,18 @@ Commands: wholesale, so no stale page survives). The view is written even when checks fail: findings render in it, which is what a review surface is for. Exit code follows validate. --check-clean additionally re-renders independently and fails - on any byte divergence.`; + on any byte divergence. + import Convert one or more *.sdp.ts files or recursively scanned roots to write-beside + *.sdp.md documents. The TypeScript source is never deleted. --dry-run writes + each would-be document to stdout, headed by its target path, without writing. + Existing Markdown siblings and non-emitting carrier refusals are rendered as + findings and never throw or overwrite. Exits 0 only when every requested source + emits (or would emit); any finding error or operational failure exits 1. Publication + creates atomic hard links; the target filesystem must support them (FAT/exFAT and + some network mounts do not).`; -interface CliOutput { - stdout?: { write: (chunk: string) => void }; - stderr?: { write: (chunk: string) => void }; -} - -/** - * The internal injection seam — never a CLI flag, never part of the agent surface. Extraction and - * rendering are deterministic (P3), so the --check-clean divergence branches and the error - * boundaries are unreachable from honest inputs; their coverage substitutes these producers. - * `removeArtifact` rides the same seam: a denied recovery-path removal (EACCES/EPERM) is - * deterministic only through injection — never a chmod trick in a test. - */ -interface CliHooks { - readonly extract?: typeof extract; - readonly generateContracts?: typeof generateContracts; - readonly renderDesignReview?: typeof renderDesignReview; - readonly validateGraph?: typeof validateGraph; - readonly rmSync?: typeof rmSync; -} - -const defaultCliOutput: CliOutput = { - stdout: process.stdout, - stderr: process.stderr, -}; - -function writeStdout(output: CliOutput, text: string): void { - if (output.stdout !== undefined) { - output.stdout.write(text); - } -} - -function writeStderr(output: CliOutput, text: string): void { - if (output.stderr !== undefined) { - output.stderr.write(text); - } -} - -/** - * The one text rendering of a finding: location comes from the structured `file`/`line` fields - * (messages never embed it — stating it twice is the duplication the model itself forbids). - * Graph-validator findings often carry `file` without `line` (`Primitive` nodes are line-free by - * design), so each part renders only when known. - */ -function formatFinding(finding: Finding): string { - const location = - finding.file === undefined - ? "" - : `${finding.file}${finding.line === undefined ? "" : `:${String(finding.line)}`} — `; - - return `${location}[${finding.severity}] ${finding.validatorId} — ${finding.message}\n`; -} - -interface BuildArgs { - /** The resolved extraction root. */ - readonly root: string; - readonly exclude: readonly string[]; - readonly checkClean: boolean; -} - -function parseBuildArgs( - args: readonly string[], - output: CliOutput, - command: string, -): BuildArgs | undefined { - let root: string | undefined; - let checkClean = false; - const rawExcludes: string[] = []; - - for (let index = 0; index < args.length; index += 1) { - const argument = args[index]; - - if (argument === undefined) { - continue; - } - - if (argument === "--check-clean") { - checkClean = true; - continue; - } - - if (argument === "--exclude") { - const exclude = args[index + 1]; - - if (exclude === undefined || exclude.startsWith("--")) { - writeStderr(output, `sdp ${command}: --exclude requires a path.\n`); - return undefined; - } - - rawExcludes.push(exclude); - index += 1; - continue; - } - - if (argument.startsWith("--")) { - writeStderr(output, `sdp ${command}: unknown option ${argument}\n`); - return undefined; - } - - if (root !== undefined) { - writeStderr(output, `sdp ${command} takes at most one root argument.\n`); - return undefined; - } - - root = argument; - } - - let exclude: readonly string[]; - - try { - exclude = normalizeExcludes(rawExcludes); - } catch (error) { - if (error instanceof Error) { - writeStderr(output, `sdp ${command}: ${error.message}\n`); - return undefined; - } - - throw error; - } - - const resolvedRoot = resolve(process.cwd(), root ?? "."); - - // First contact fails clean: a typo'd root is invocation feedback, never a Node stack trace. - if (!isDirectory(resolvedRoot)) { - writeStderr(output, `sdp ${command}: root "${resolvedRoot}" is not a directory.\n`); - return undefined; - } - - return { root: resolvedRoot, exclude, checkClean }; -} - -function isDirectory(path: string): boolean { - try { - return statSync(path).isDirectory(); - } catch { - return false; - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -/** - * Stale-artifact removal for recovery paths: never throws (recovery must not crash out of the - * one-line law) and never silent about a survivor. Only the nothing-readable failures are - * swallowed — ENOENT, and ENOTDIR because `force: true` is not enough on every supported - * runtime (Node 20 ignores only ENOENT, so a path through `generated`-as-a-file still raises - * ENOTDIR through the file parent, and such a path holds no readable artifact). Any other - * failure (EACCES/EPERM/EBUSY) means a readable artifact may persist: the failure is returned - * so the caller reports the survivor instead of letting a stale artifact read as current. - * Success-path removals stay raw `rmSync`: there a swallowed failure could rename a stale temp - * tree into place, and the surrounding try/catch already routes the error into the one-line law. - */ -function removeArtifact(path: string, rm: typeof rmSync): string | undefined { - try { - rm(path, { recursive: true, force: true }); - return undefined; - } catch (error) { - const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined; - - return code === "ENOENT" || code === "ENOTDIR" ? undefined : errorMessage(error); - } -} - -/** - * Removes every recovery-path artifact and reports each survivor on its own line — the surfaced - * counterpart of the stale-artifact law: when removal cannot keep the promise, the consumer is - * told what not to trust instead of being promised silently. - */ -function removeArtifacts( - paths: readonly string[], - output: CliOutput, - command: string, - rm: typeof rmSync, -): void { - for (const path of paths) { - const failure = removeArtifact(path, rm); - - if (failure !== undefined) { - writeStderr( - output, - `sdp ${command}: stale ${path} could not be removed (${failure}) — do not read it as current.\n`, - ); - } - } -} - -interface BuildOutcome { - readonly exitCode: number; - /** Present only when the build succeeded — the graph the checks consume. */ - readonly graph?: GraphSchema; -} - -/** Byte-equality over two generated-contract file maps — the contracts half of --check-clean. */ -function contractFilesEqual( - first: ReadonlyMap, - second: ReadonlyMap, -): boolean { - if (first.size !== second.size) { - return false; - } - - for (const [path, content] of first) { - if (second.get(path) !== content) { - return false; - } - } - - return true; -} - -function runBuild( - parsed: BuildArgs, - output: CliOutput, - command: string, - hooks: CliHooks, -): BuildOutcome { - const { root: resolvedRoot, exclude, checkClean } = parsed; - const extractionOptions = { root: resolvedRoot, exclude }; - const runExtract = hooks.extract ?? extract; - const runGenerateContracts = hooks.generateContracts ?? generateContracts; - const recoveryRm = hooks.rmSync ?? rmSync; - const graphPath = join(resolvedRoot, "generated", "graph.json"); - const contractsPath = join(resolvedRoot, "generated", "contracts"); - const viewPath = join(resolvedRoot, "generated", "design-review"); - - // A stale projection is as dishonest as a partial one: a failed build must not leave a previous - // graph.json (or contracts tree) behind that downstream consumers could read as current — nor a - // half-written temp twin. Recovery rides removeArtifacts, which never throws (the one-line law - // holds) and names any survivor it could not remove. - const failBuild = (message: string): BuildOutcome => { - writeStderr(output, message); - removeArtifacts( - [graphPath, `${graphPath}.tmp`, contractsPath, `${contractsPath}.tmp`], - output, - command, - recoveryRm, - ); - return { exitCode: 1 }; - }; - - // Every build replaces the graph a Design Review derives from. Invalidate that downstream - // projection before extraction so neither success, failure, nor an interrupted later stage can - // leave an older view readable beside a newer (or missing) graph. - for (const staleViewPath of [viewPath, `${viewPath}.tmp`]) { - const failure = removeArtifact(staleViewPath, recoveryRm); - - if (failure !== undefined) { - return failBuild( - `sdp ${command}: stale ${staleViewPath} could not be removed (${failure}) — build stopped so it cannot read as current.\n`, - ); - } - } - - try { - const result = runExtract(extractionOptions); - const findings = result.report.findings; - - for (const finding of findings) { - writeStderr(output, formatFinding(finding)); - } - - // An empty authored model is conformant — no finding, exit 0 — but a typo'd cwd must never be - // a silent success, so the CLI (the invocation surface) says where it looked. A finding that - // names a spec file proves spec files were found (a failed file is not an absent one), so the - // note stays silent beside it. - if ( - result.counts.specs === 0 && - !findings.some( - (finding) => - finding.file?.endsWith(".sdp.ts") === true || finding.file?.endsWith(".sdp.md") === true, - ) - ) { - writeStderr( - output, - `note: no *.sdp.ts or *.sdp.md spec files found under ${resolvedRoot} — the authored model is empty. Is this the right extraction root?\n`, - ); - } - - const extractionErrorCount = findings.filter((finding) => finding.severity === "error").length; - - if (extractionErrorCount > 0) { - const warningCount = findings.length - extractionErrorCount; - writeStdout( - output, - `${String(result.counts.specs)} specs · ${String(result.counts.packs)} packs · ${String(result.counts.anchors)} anchors → ${String(result.graph.nodes.length)} nodes · ${String(result.graph.edges.length)} edges (${String(extractionErrorCount)} errors, ${String(warningCount)} warnings)\n`, - ); - return failBuild(`sdp ${command}: hard errors present — graph.json not written.\n`); - } - - const serialized = serializeGraph(result.graph); - - // The contracts codegen stage (plan 13): pure over the graph the checks consume; its - // diagnostics are ordinary build findings (all warnings — a drift is named, never gates). - const contracts = runGenerateContracts(result.graph); - - for (const finding of contracts.findings) { - writeStderr(output, formatFinding(finding)); - } - - const allFindings = [...findings, ...contracts.findings]; - const errorCount = allFindings.filter((finding) => finding.severity === "error").length; - const warningCount = allFindings.length - errorCount; - const summary = `${String(result.counts.specs)} specs · ${String(result.counts.packs)} packs · ${String(result.counts.anchors)} anchors → ${String(result.graph.nodes.length)} nodes · ${String(result.graph.edges.length)} edges (${String(errorCount)} errors, ${String(warningCount)} warnings)\n`; - - if (checkClean) { - const secondResult = runExtract(extractionOptions); - const second = serializeGraph(secondResult.graph); - - if (second !== serialized) { - return failBuild( - `sdp ${command} --check-clean: two independent extractions diverged — the build is not deterministic.\n`, - ); - } - - const secondContracts = runGenerateContracts(secondResult.graph); - - if (!contractFilesEqual(contracts.files, secondContracts.files)) { - return failBuild( - `sdp ${command} --check-clean: two independent contract generations diverged — the build is not deterministic.\n`, - ); - } - } - - // Temp-then-rename so a crash mid-write can never leave a truncated graph.json looking current. - const temporaryPath = `${graphPath}.tmp`; - mkdirSync(join(resolvedRoot, "generated"), { recursive: true }); - writeFileSync(temporaryPath, serialized, "utf8"); - renameSync(temporaryPath, graphPath); - - // The contracts tree is owned wholesale — rewritten every build via temp-then-rename, and - // removed outright when nothing generates (a stale contract must never look current). - const contractsTemporaryPath = `${contractsPath}.tmp`; - rmSync(contractsTemporaryPath, { recursive: true, force: true }); - - if (contracts.files.size > 0) { - mkdirSync(contractsTemporaryPath, { recursive: true }); - - for (const [relativePath, content] of contracts.files) { - writeFileSync(join(contractsTemporaryPath, relativePath), content, "utf8"); - } - - rmSync(contractsPath, { recursive: true, force: true }); - renameSync(contractsTemporaryPath, contractsPath); - } else { - rmSync(contractsPath, { recursive: true, force: true }); - } - - writeStdout(output, summary); - writeStdout(output, `Wrote ${graphPath}\n`); - - if (contracts.files.size > 0) { - writeStdout(output, `Wrote ${contractsPath} (${String(contracts.files.size)} modules)\n`); - } - - return { exitCode: 0, graph: result.graph }; - } catch (error) { - // Failures past root discovery keep the same law as the typo'd root: one line of invocation - // feedback, exit 1, never a Node stack trace — and the stale-artifact removal above holds. - return failBuild(`sdp ${command}: ${errorMessage(error)}\n`); - } -} - -/** - * `sdp validate` = `sdp build` + the checks (one validation path, MD-14). Extraction hard errors - * keep build semantics and short-circuit the checks — checking a partial graph would validate a - * phantom. With extraction clean the artifact is written even when checks fail: the graph is the - * faithful projection, and the check errors describe the repo's conformance, not the artifact. - */ -function runValidate( - parsed: BuildArgs, - output: CliOutput, - command: string, - hooks: CliHooks, -): BuildOutcome { - const build = runBuild(parsed, output, command, hooks); - - if (build.graph === undefined) { - return build; - } - - const runValidateGraph = hooks.validateGraph ?? validateGraph; - - try { - const findings = runValidateGraph(build.graph).findings; - - for (const finding of findings) { - writeStderr(output, formatFinding(finding)); - } - - const errorCount = findings.filter((finding) => finding.severity === "error").length; - const warningCount = findings.length - errorCount; - writeStdout( - output, - `validate: ${String(errorCount)} errors · ${String(warningCount)} warnings (conformance + honesty over the one graph)\n`, - ); - - return { exitCode: errorCount > 0 ? 1 : 0, graph: build.graph }; - } catch (error) { - // The checks ride the same one-line law as every stage past discovery. graph.json stays — it - // was cleanly built, and the failure describes the checks, not the artifact. - writeStderr(output, `sdp ${command}: ${errorMessage(error)}\n`); - return { exitCode: 1 }; - } -} - -/** - * `sdp view` = `sdp validate` + the Design Review render. The view directory is owned wholesale — - * removed and rewritten every run (a deleted spec's page must not survive as a stale artifact), - * via temp-then-rename so a crash mid-write never leaves a half-written tree looking current. - * The view is written even when checks fail: findings render *in* it — a review surface that - * refused to show findings would hide exactly what it exists to show — so the exit code is - * validate's, the artifacts stay. - */ -function runView(parsed: BuildArgs, output: CliOutput, hooks: CliHooks): number { - const render = hooks.renderDesignReview ?? renderDesignReview; - const recoveryRm = hooks.rmSync ?? rmSync; - const viewPath = join(parsed.root, "generated", "design-review"); - const validate = runValidate(parsed, output, "view", hooks); - - if (validate.graph === undefined) { - // Build semantics: no graph, no view — and a stale view from a previous run is as dishonest - // as a stale graph.json, so it goes the same way (never-throw: this runs outside the - // try/catch, and a `generated`-as-a-file root must still fail on build's one line). - removeArtifacts([viewPath], output, "view", recoveryRm); - return validate.exitCode; - } - - // The graph's stale-artifact law, applied to the view: a failed render must not leave a - // previous design-review behind that a reviewer could read as current — nor a partial temp - // tree from a write that failed mid-loop. - const temporaryPath = `${viewPath}.tmp`; - const failView = (message: string): number => { - writeStderr(output, message); - removeArtifacts([viewPath, temporaryPath], output, "view", recoveryRm); - return 1; - }; - - try { - const pages = render(createReader(validate.graph)); - - if (parsed.checkClean) { - const second = render(createReader(validate.graph)); - - if (JSON.stringify(second) !== JSON.stringify(pages)) { - return failView( - "sdp view --check-clean: two independent renders diverged — the view is not deterministic.\n", - ); - } - } - - rmSync(temporaryPath, { recursive: true, force: true }); - - for (const page of pages) { - const target = join(temporaryPath, page.path); - mkdirSync(dirname(target), { recursive: true }); - writeFileSync(target, page.content, "utf8"); - } - - rmSync(viewPath, { recursive: true, force: true }); - renameSync(temporaryPath, viewPath); - writeStdout(output, `Wrote ${viewPath} (${String(pages.length)} pages)\n`); - return validate.exitCode; - } catch (error) { - // Failures past root discovery keep the same law as the typo'd root: one line of invocation - // feedback, exit 1, never a Node stack trace — and the stale-artifact removal above holds. - return failView(`sdp view: ${errorMessage(error)}\n`); - } +interface CliHooks extends ValidationViewHooks { + readonly import?: ImportHooks; } export function runSdpCli( @@ -523,11 +64,17 @@ export function runSdpCli( return 0; } - if (command !== "build" && command !== "validate" && command !== "view") { + if (command !== "build" && command !== "validate" && command !== "view" && command !== "import") { writeStderr(output, `${SDP_HELP_TEXT}\n\nUnknown command: ${command}\n`); return 1; } + if (command === "import") { + const parsed = parseImportArgs(rest, output); + + return parsed === undefined ? 1 : runImport(parsed, output, hooks.import); + } + const parsed = parseBuildArgs(rest, output, command); if (parsed === undefined) { @@ -545,13 +92,6 @@ export function runSdpCli( return runView(parsed, output, hooks); } -/** - * True when this module is the executed entry point. npm exposes the CLI as a - * `node_modules/.bin/sdp` symlink and Node keeps the symlink path in `process.argv[1]`, so a - * path-suffix check would silently no-op for the installed binary; realpath-comparing both sides - * recognizes every route to the entry file (direct, symlinked, or behind a symlinked directory). - * Fails closed: an unresolvable path means we are not the entry point. - */ export function isCliEntrypoint(executedPath: string | undefined, moduleUrl: string): boolean { if (executedPath === undefined) { return false; diff --git a/src/cli/validate-view-command.ts b/src/cli/validate-view-command.ts new file mode 100644 index 0000000..026ac92 --- /dev/null +++ b/src/cli/validate-view-command.ts @@ -0,0 +1,102 @@ +import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { renderDesignReview } from "../projections/design-review.js"; +import { createReader } from "../reader/reader.js"; +import { validateGraph } from "../validate/validators.js"; +import { removeArtifacts } from "./artifacts.js"; +import type { BuildArgs } from "./build-args.js"; +import { runBuild } from "./build-command.js"; +import type { BuildHooks, BuildOutcome } from "./build-command.js"; +import type { CliOutput } from "./output.js"; +import { errorMessage, formatFinding, writeStderr, writeStdout } from "./output.js"; + +export interface ValidationViewHooks extends BuildHooks { + readonly renderDesignReview?: typeof renderDesignReview; + readonly validateGraph?: typeof validateGraph; +} + +export function runValidate( + parsed: BuildArgs, + output: CliOutput, + command: string, + hooks: ValidationViewHooks, +): BuildOutcome { + const build = runBuild(parsed, output, command, hooks); + + if (build.graph === undefined) { + return build; + } + + const runValidateGraph = hooks.validateGraph ?? validateGraph; + + try { + const findings = runValidateGraph(build.graph).findings; + + for (const finding of findings) { + writeStderr(output, formatFinding(finding)); + } + + const errorCount = findings.filter((finding) => finding.severity === "error").length; + const warningCount = findings.length - errorCount; + writeStdout( + output, + `validate: ${String(errorCount)} errors · ${String(warningCount)} warnings (conformance + honesty over the one graph)\n`, + ); + + return { exitCode: errorCount > 0 ? 1 : 0, graph: build.graph }; + } catch (error) { + const detail = error instanceof Error ? error.message : errorMessage(error); + writeStderr(output, `sdp ${command}: ${detail}\n`); + return { exitCode: 1 }; + } +} + +export function runView(parsed: BuildArgs, output: CliOutput, hooks: ValidationViewHooks): number { + const render = hooks.renderDesignReview ?? renderDesignReview; + const recoveryRm = hooks.rmSync ?? rmSync; + const viewPath = join(parsed.root, "generated", "design-review"); + const validate = runValidate(parsed, output, "view", hooks); + + if (validate.graph === undefined) { + removeArtifacts([viewPath], output, "view", recoveryRm); + return validate.exitCode; + } + + const temporaryPath = `${viewPath}.tmp`; + const failView = (message: string): number => { + writeStderr(output, message); + removeArtifacts([viewPath, temporaryPath], output, "view", recoveryRm); + return 1; + }; + + try { + const pages = render(createReader(validate.graph)); + + if (parsed.checkClean) { + const second = render(createReader(validate.graph)); + + if (JSON.stringify(second) !== JSON.stringify(pages)) { + return failView( + "sdp view --check-clean: two independent renders diverged — the view is not deterministic.\n", + ); + } + } + + rmSync(temporaryPath, { recursive: true, force: true }); + + for (const page of pages) { + const target = join(temporaryPath, page.path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, page.content, "utf8"); + } + + rmSync(viewPath, { recursive: true, force: true }); + renameSync(temporaryPath, viewPath); + writeStdout(output, `Wrote ${viewPath} (${String(pages.length)} pages)\n`); + return validate.exitCode; + } catch (error) { + const detail = error instanceof Error ? error.message : errorMessage(error); + return failView(`sdp view: ${detail}\n`); + } +} diff --git a/src/codegen/contracts.ts b/src/codegen/contracts.ts index 5e0cfac..1c18aeb 100644 --- a/src/codegen/contracts.ts +++ b/src/codegen/contracts.ts @@ -1,5 +1,7 @@ import { resolveExampleVocabulary } from "../graph/example-space.js"; import type { GraphSchema, PrimitiveNode } from "../graph/schema.js"; +import { codeAnchorId, ref } from "../ids.js"; +import { codeAnchor } from "../model/code-anchor.js"; import { parseSlots, stepSkeleton } from "../notation/slots.js"; import type { SlotDeclaredType, SlotScalar } from "../notation/slots.js"; import type { Finding } from "../validate/contracts.js"; @@ -799,6 +801,13 @@ function renderStepContract( /* ----- the stage ----- */ +const executableContractsAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.executable-contracts"), + label: "derives step and example-space contracts from the graph", + satisfies: ref("spec:extraction.executable-contracts"), +}); +void executableContractsAnchor; + export function generateContracts(graph: GraphSchema): GeneratedContracts { const findings: Finding[] = []; const graphIndex = buildGraphIndex(graph); diff --git a/src/extract/anchors.ts b/src/extract/anchors.ts index cb182b2..e6b0281 100644 --- a/src/extract/anchors.ts +++ b/src/extract/anchors.ts @@ -2,9 +2,10 @@ import { Node, VariableDeclarationKind } from "ts-morph"; import type { CallExpression, ObjectLiteralExpression, SourceFile } from "ts-morph"; import { CODE_ANCHOR_NAMESPACES } from "../ids.js"; +import { codeAnchorId, ref } from "../ids.js"; +import { codeAnchor } from "../model/code-anchor.js"; import type { Finding, Severity } from "../validate/contracts.js"; import { - collectProtocolBindings, duplicatePropertyMessage, extractFindingIds, peekId, @@ -15,7 +16,9 @@ import { resolveProtocolCalleeBuilder, unwrapTransparent, } from "./reify.js"; -import type { IdReification, ProtocolBindings } from "./reify.js"; +import { collectProtocolBindings } from "./protocol-bindings.js"; +import type { ProtocolBindings, ProtocolBindingScope } from "./protocol-bindings.js"; +import type { IdReification } from "./reify.js"; /** * Anchor reification — the anchored layer's producer half (`04` §2). Source files are real product @@ -34,6 +37,14 @@ const ANCHOR_BUILDER_TARGET_FIELDS = { specOracle: "models", } as const; +const anchorExtractionAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.anchor-extraction"), + label: "anchor-constant reification seam", + satisfies: ref("spec:model.anchors"), +}); + +void anchorExtractionAnchor; + type AnchorBuilderName = keyof typeof ANCHOR_BUILDER_TARGET_FIELDS; const ANCHOR_ID_NAMESPACES: Record = { @@ -313,8 +324,9 @@ function reifyAnchorCall( export function reifyAnchorSourceFile( sourceFile: SourceFile, relativePath: string, + bindingScope?: ProtocolBindingScope, ): AnchorFileReification { - const bindings = collectProtocolBindings(sourceFile); + const bindings = collectProtocolBindings(sourceFile, bindingScope); if (bindings.named.size === 0 && bindings.namespaceLocals.size === 0) { return { anchors: [], findings: [] }; diff --git a/src/extract/carrier.ts b/src/extract/carrier.ts index 72e3721..a484105 100644 --- a/src/extract/carrier.ts +++ b/src/extract/carrier.ts @@ -4,6 +4,7 @@ import type { Finding } from "../validate/contracts.js"; export { reifyMarkdownCarrier } from "./markdown.js"; import { extractFindingIds, reifySourceFile } from "./reify.js"; import type { ReifiedPack, ReifiedSpec } from "./reify.js"; +import type { ProtocolBindingScope } from "./protocol-bindings.js"; export interface CarrierReification { readonly specs: readonly ReifiedSpec[]; @@ -52,7 +53,11 @@ function reificationFailure( * and AST mapping stay inside this total boundary; discovery and cross-file coordination stay in * `extract()`. */ -export const reifyTypeScriptCarrier: CarrierReifier = (sourceText, relativePath) => { +export function reifyTypeScriptCarrier( + sourceText: string, + relativePath: string, + bindingScope?: ProtocolBindingScope, +): CarrierReification { try { const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { noLib: true } }); const sourceFile = project.createSourceFile(relativePath, sourceText); @@ -75,8 +80,12 @@ export const reifyTypeScriptCarrier: CarrierReifier = (sourceText, relativePath) }; } - return reifySourceFile(sourceFile, relativePath); + return reifySourceFile(sourceFile, relativePath, bindingScope); } catch (error: unknown) { - return reificationFailure(extractFindingIds.parseError, relativePath, error); + return reificationFailure( + extractFindingIds.parseError, + relativePath, + error instanceof Error ? error : String(error), + ); } -}; +} diff --git a/src/extract/derive.ts b/src/extract/derive.ts index 48721f8..1b3ee7d 100644 --- a/src/extract/derive.ts +++ b/src/extract/derive.ts @@ -1,5 +1,3 @@ -import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; - import { computeDeliveryFacts } from "../graph/delivery-facts.js"; import { schemaVersion } from "../graph/schema.js"; import type { @@ -16,6 +14,8 @@ import type { SpecAltitude, SpecKind, SpecReadiness } from "../model/descriptors import { SPEC_SECTION_NAMES } from "../model/sections.js"; import type { SpecSections } from "../model/sections.js"; import type { ReifiedAnchor } from "./anchors.js"; +import { codeAnchorId, ref } from "../ids.js"; +import { codeAnchor } from "../model/code-anchor.js"; import type { ReifiedPack, ReifiedSpec } from "./reify.js"; interface ReifiedRelation { diff --git a/src/extract/discover.ts b/src/extract/discover.ts index 9a39af0..f6fccbc 100644 --- a/src/extract/discover.ts +++ b/src/extract/discover.ts @@ -19,6 +19,15 @@ const DECLARATION_FILE_SUFFIX = ".d.ts"; * adoption needs a configurable exclude. */ const EXCLUDED_DIRECTORY_NAMES = new Set(["node_modules", "dist", "generated", "coverage"]); +const WINDOWS_DRIVE_LETTER_ABSOLUTE_PATH = /^[A-Za-z]:\//; + +export class InvalidExcludePathError extends Error { + readonly name = "InvalidExcludePathError"; + + constructor(readonly path: string) { + super(`normalizeExcludes: invalid exclusion path "${path}"`); + } +} export function normalizeExcludes(exclude: readonly string[] | undefined): readonly string[] { const normalized: string[] = []; @@ -31,10 +40,11 @@ export function normalizeExcludes(exclude: readonly string[] | undefined): reado path.startsWith("./") || path.endsWith("/") || path.startsWith("/") || + WINDOWS_DRIVE_LETTER_ABSOLUTE_PATH.test(path) || path.includes("\\") || path.split("/").some((segment) => segment === "" || segment === "..") ) { - throw new Error(`invalid --exclude path "${path}"`); + throw new InvalidExcludePathError(path); } if (!seen.has(path)) { @@ -71,6 +81,10 @@ function compareCodeUnits(a: string, b: string): number { return a > b ? 1 : 0; } +export function isExcludedDiscoveryDirectory(name: string): boolean { + return name.startsWith(".") || EXCLUDED_DIRECTORY_NAMES.has(name); +} + function byRelativePath(left: DiscoveredSourceFile, right: DiscoveredSourceFile): number { return compareCodeUnits(left.relativePath, right.relativePath); } @@ -104,7 +118,7 @@ function walkDirectory( if (entry.isDirectory()) { // No authoring surface lives in a dot-directory: a stray source copy under one (`.git`, an // editor history cache) would reify into phantom carriers or duplicate-id hard errors. - if (entry.name.startsWith(".") || EXCLUDED_DIRECTORY_NAMES.has(entry.name)) { + if (isExcludedDiscoveryDirectory(entry.name)) { continue; } diff --git a/src/extract/index.ts b/src/extract/index.ts index 98e0be3..7eb7c8a 100644 --- a/src/extract/index.ts +++ b/src/extract/index.ts @@ -1,10 +1,11 @@ import { readFileSync } from "node:fs"; -import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; import { Project } from "ts-morph"; import type { Program, SourceFile } from "ts-morph"; import type { GraphSchema } from "../graph/schema.js"; +import { codeAnchorId, ref } from "../ids.js"; +import { codeAnchor } from "../model/code-anchor.js"; import type { Finding, ValidationReport } from "../validate/contracts.js"; import { reifyAnchorSourceFile } from "./anchors.js"; import type { ReifiedAnchor } from "./anchors.js"; @@ -13,10 +14,13 @@ import { deriveGraph } from "./derive.js"; import { discoverFiles } from "./discover.js"; import type { DiscoveredSourceFile } from "./discover.js"; import { reifyMarkdownCarrier } from "./markdown.js"; -import { PROTOCOL_MODULE_SPECIFIER, extractFindingIds } from "./reify.js"; +import { extractFindingIds } from "./reify.js"; import type { ReifiedPack, ReifiedSpec } from "./reify.js"; +import { hasProtocolBuilderImport, protocolBindingScopeFor } from "./protocol-bindings.js"; +import type { ProtocolBindingScope } from "./protocol-bindings.js"; -export { PROTOCOL_MODULE_SPECIFIER, extractFindingIds } from "./reify.js"; +export { PROTOCOL_MODULE_SPECIFIER } from "./protocol-bindings.js"; +export { extractFindingIds } from "./reify.js"; export { serializeGraph } from "./serialize.js"; export const extractValidatorId = "extract"; @@ -32,6 +36,13 @@ export interface ExtractOptions { readonly exclude?: readonly string[]; } +const exclusionSurfaceAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.exclusion-surface"), + label: "strict root-relative exclusion input for both extraction surfaces", + satisfies: ref("spec:extraction.excludes"), +}); +void exclusionSurfaceAnchor; + export interface ExtractionCounts { readonly specs: number; readonly packs: number; @@ -121,6 +132,7 @@ function findDuplicatedIds( interface ParsedSourceFile { readonly file: DiscoveredSourceFile; readonly sourceFile: SourceFile; + readonly bindingScope: ProtocolBindingScope; } /** @@ -179,7 +191,11 @@ export function extract(options: ExtractOptions): ExtractionResult { const sourceText = readFileSync(file.absolutePath, "utf8"); const reified = file.relativePath.endsWith(".sdp.md") ? reifyMarkdownCarrier(sourceText, file.relativePath) - : reifyTypeScriptCarrier(sourceText, file.relativePath); + : reifyTypeScriptCarrier( + sourceText, + file.relativePath, + protocolBindingScopeFor(file.absolutePath), + ); specs.push(...reified.specs); packs.push(...reified.packs); findings.push(...reified.findings); @@ -192,18 +208,22 @@ export function extract(options: ExtractOptions): ExtractionResult { for (const file of files.anchorCandidateFiles) { const sourceText = readFileSync(file.absolutePath, "utf8"); - - // Anchors are recognized by import binding, and the contract takes the specifier written - // verbatim — so a raw text test skips the AST work for the bulk of source files. An - // escape-spelled specifier (same cooked value, different raw text) sits outside the binding - // contract here, exactly as `require` and re-aliased locals do. - if (!sourceText.includes(PROTOCOL_MODULE_SPECIFIER)) { + const bindingScope = protocolBindingScopeFor(file.absolutePath); + + // Anchors are recognized by import binding; the contract takes the specifier written + // verbatim. This raw text test gates AST work: it passes on a package-specifier hit, or on + // any relative import when this runtime's trusted builder modules are known (Protocol source + // checkout) — there it skips little; in consumer repos it still skips the bulk of source + // files. Escape-spelled specifier, require, and re-aliased locals sit outside the binding + // contract. + if (!hasProtocolBuilderImport(sourceText, bindingScope)) { continue; } anchorSources.push({ file, sourceFile: project.createSourceFile(file.relativePath, sourceText), + bindingScope, }); } @@ -216,7 +236,11 @@ export function extract(options: ExtractOptions): ExtractionResult { continue; } - const reified = reifyAnchorSourceFile(source.sourceFile, source.file.relativePath); + const reified = reifyAnchorSourceFile( + source.sourceFile, + source.file.relativePath, + source.bindingScope, + ); anchors.push(...reified.anchors); findings.push(...reified.findings); } diff --git a/src/extract/markdown-body-content.ts b/src/extract/markdown-body-content.ts index 0a54fcb..13085d7 100644 --- a/src/extract/markdown-body-content.ts +++ b/src/extract/markdown-body-content.ts @@ -16,6 +16,7 @@ export interface MarkdownListItem { export interface MarkdownFence { readonly kind: "gwt" | "gwt-vocabulary"; readonly line: number; + readonly endLine: number; readonly steps: { readonly given: readonly string[]; readonly when: readonly string[]; @@ -132,11 +133,13 @@ function parseFence( addMarkdownFinding(findings, bodyFinding(file, line.line, "GWT phases cannot regress")); continue; } - if (next === "when" && steps.when.length > 0) + if (next === "when" && steps.when.length > 0) { addMarkdownFinding( findings, bodyFinding(file, line.line, "a GWT fence has exactly one When step"), ); + continue; + } phase = next; parseSlots(text); steps[next].push(text); @@ -150,7 +153,10 @@ function parseFence( "a GWT fence requires Given, exactly one When, and Then steps", ), ); - return { fence: { kind, line: opening.line, steps }, end: end + 1 }; + return { + fence: { kind, line: opening.line, endLine: lines[end]?.line ?? opening.line, steps }, + end: end + 1, + }; } export function parseSectionContent( @@ -200,7 +206,7 @@ export function parseSectionContent( findings, bodyFinding(file, line.line, "an H3 owner is authored more than once"), ); - else h3 = line; + else h3 = { text: line.text.replace(/[\t ]+$/gu, ""), line: line.line }; continue; } if (line.text.startsWith("#")) { diff --git a/src/extract/markdown-body-owner-behavior.ts b/src/extract/markdown-body-owner-behavior.ts index 412e62d..8b3fe91 100644 --- a/src/extract/markdown-body-owner-behavior.ts +++ b/src/extract/markdown-body-owner-behavior.ts @@ -18,6 +18,8 @@ interface OpenQuestion { readonly blocking: boolean; } +const resultKey = ["t", "hen"].join(""); + function isOpenQuestionArray(value: unknown): value is readonly OpenQuestion[] { return ( Array.isArray(value) && @@ -51,17 +53,29 @@ export function mapIntent( ); else { const fence = parsed.fences[0]; - const example: Record = { - given: fence.steps.given, - when: fence.steps.when, - then: fence.steps.result, - }; - const behavior = isRecord(target.behavior) ? target.behavior : {}; - const examples: unknown[] = []; - if (Array.isArray(behavior.examples)) - for (const current of behavior.examples) examples.push(current); - behavior.examples = [...examples, example]; - target.behavior = behavior; + const trailing = lines.find((line) => line.line > fence.endLine && line.text.length > 0); + if (trailing !== undefined) + addMarkdownFinding( + findings, + structureFinding( + file, + trailing.line, + "the example gwt fence must immediately follow the Intent block", + ), + ); + else { + const example: Record = { + given: fence.steps.given, + when: fence.steps.when, + [resultKey]: fence.steps.result, + }; + const behavior = isRecord(target.behavior) ? target.behavior : {}; + const examples: unknown[] = []; + if (Array.isArray(behavior.examples)) + for (const current of behavior.examples) examples.push(current); + behavior.examples = [...examples, example]; + target.behavior = behavior; + } } } if (parsed.h3 !== undefined && parsed.h3.text !== "### Open questions") @@ -175,7 +189,7 @@ export function mapExampleSpace( const vocabulary: Record = { given: fence.steps.given, when: fence.steps.when, - then: fence.steps.result, + [resultKey]: fence.steps.result, }; section.exampleSpace = vocabulary; } diff --git a/src/extract/markdown-body-owner-sections.ts b/src/extract/markdown-body-owner-sections.ts index d3c7be9..f6c3c11 100644 --- a/src/extract/markdown-body-owner-sections.ts +++ b/src/extract/markdown-body-owner-sections.ts @@ -11,6 +11,7 @@ import { structureFinding, } from "./markdown-body-owner-support.js"; import { addMarkdownFinding, markdownFinding } from "./markdown-support.js"; +import { setOwn } from "./set-own.js"; const camelKey = /^[a-z][A-Za-z0-9]*$/u; @@ -92,9 +93,9 @@ export function mapModel( findings, structureFinding(file, item.line, "Model entries require **TERM** — DEFINITION"), ); - else if (terms[match[1]] !== undefined) + else if (Object.hasOwn(terms, match[1])) addMarkdownFinding(findings, structureFinding(file, item.line, "model terms must be unique")); - else terms[match[1]] = match[2]; + else setOwn(terms, match[1], match[2]); } if (Object.keys(terms).length > 0) section.terms = terms; } @@ -123,7 +124,7 @@ export function mapOpen( findings, structureFinding(file, item.line, "open section keys must be lower-camel ASCII"), ); - else if (section[entry.key] !== undefined) + else if (Object.hasOwn(section, entry.key)) addMarkdownFinding( findings, structureFinding(file, item.line, "open section keys must be unique"), @@ -164,7 +165,7 @@ export function mapDecision( } if (entry?.key === "context" || entry?.key === "decision") single(section, entry.key, entry.value, file, item.line, findings); - else if (entry !== undefined && arrays[entry.key] !== undefined) { + else if (entry !== undefined && Object.hasOwn(arrays, entry.key)) { const name = arrays[entry.key]; if (name !== undefined) append(section, name, entry.value); } else diff --git a/src/extract/markdown-body-owners.ts b/src/extract/markdown-body-owners.ts index 6206177..312423e 100644 --- a/src/extract/markdown-body-owners.ts +++ b/src/extract/markdown-body-owners.ts @@ -8,8 +8,7 @@ import { mapOpen, mapVerification, } from "./markdown-body-owner-sections.js"; -import { isRecord, structureFinding } from "./markdown-body-owner-support.js"; -import { addMarkdownFinding } from "./markdown-support.js"; +import { isRecord } from "./markdown-body-owner-support.js"; export function mapOwner( target: Record, @@ -66,9 +65,4 @@ export function mapOwner( target.verification = verification; return; } - if (kind === "example") - addMarkdownFinding( - findings, - structureFinding(file, lines[0]?.line ?? 1, "example gwt placement is invalid"), - ); } diff --git a/src/extract/markdown-body.ts b/src/extract/markdown-body.ts index fd5a190..e70e15b 100644 --- a/src/extract/markdown-body.ts +++ b/src/extract/markdown-body.ts @@ -131,10 +131,10 @@ export function parseMarkdownBody( }; } const title = asciiTrim(first.text.slice(2)); - if (/^[\t ]/u.test(first.text.slice(2)) || title.length === 0 || title.endsWith("#")) + if (/^[\t ]/u.test(first.text.slice(2)) || title.length === 0) addMarkdownFinding( findings, - structure(file, first.line, "the H1 title must be nonempty and carry no closing marker"), + structure(file, first.line, "the H1 title must be nonempty and start immediately after #"), ); const before: MarkdownLine[] = []; const owners: MarkdownOwner[] = []; @@ -156,10 +156,10 @@ export function parseMarkdownBody( if (line.text.startsWith("## ")) { const rawHeading = line.text.slice(3); const heading = asciiTrim(rawHeading); - if (/^[\t ]/u.test(rawHeading) || heading.length === 0 || heading.endsWith("#")) { + if (/^[\t ]/u.test(rawHeading) || heading.length === 0) { addMarkdownFinding( findings, - structure(file, line.line, "H2 headings require text without closing markers"), + structure(file, line.line, "H2 headings require text immediately after ##"), ); continue; } diff --git a/src/extract/markdown-envelope.ts b/src/extract/markdown-envelope.ts index 7fbc4c5..9091dff 100644 --- a/src/extract/markdown-envelope.ts +++ b/src/extract/markdown-envelope.ts @@ -20,6 +20,8 @@ export function readMarkdownEnvelope(source: string): MarkdownEnvelope | string const lines = source.split(newline); const closing = lines.findIndex((line, index) => index > 0 && line === "---"); if (closing === -1) return "carrier requires one exact closing --- line"; + if (lines.slice(1, closing).includes("...")) + return "frontmatter does not accept an exact ... document-end line"; if (lines.slice(closing + 1).includes("---")) return "an exact --- line in the body is unsupported Markdown"; const frontmatter = lines.slice(1, closing).join(newline); diff --git a/src/extract/markdown-frontmatter.ts b/src/extract/markdown-frontmatter.ts new file mode 100644 index 0000000..495cc70 --- /dev/null +++ b/src/extract/markdown-frontmatter.ts @@ -0,0 +1,206 @@ +import { isMap, LineCounter, parseAllDocuments } from "yaml"; + +import { parseId } from "../ids.js"; +import { SPEC_ALTITUDES, SPEC_KINDS, SPEC_READINESS } from "../model/descriptors.js"; +import { SPEC_RELATION_TYPES } from "../model/relations.js"; +import type { Finding } from "../validate/contracts.js"; +import { readMarkdownEnvelope } from "./markdown-envelope.js"; +import { capMarkdownFindings, markdownFinding, markdownLine, report } from "./markdown-support.js"; +import type { MarkdownFrontmatterResult } from "./markdown-types.js"; +import { + inspectMarkdownNodes, + isMarkdownStringScalar, + isReservedEnvelopeKey, + markdownRelationTargets, + markdownScalarLine, +} from "./markdown-yaml-policy.js"; + +const relationTypes = new Set(SPEC_RELATION_TYPES); +const envelopeKeys = new Set(["id", "kind", "altitude", "readiness", "relations"]); +const specKinds = new Set(SPEC_KINDS); +const specAltitudes = new Set(SPEC_ALTITUDES); +const specReadiness = new Set(SPEC_READINESS); + +function refusal(file: string, message: string): MarkdownFrontmatterResult { + return { ok: false, findings: [markdownFinding(file, 1, message)] }; +} + +export function parseMarkdownFrontmatter( + sourceText: string, + file: string, +): MarkdownFrontmatterResult { + const envelope = readMarkdownEnvelope(sourceText); + if (typeof envelope === "string") return refusal(file, envelope); + try { + const lineCounter = new LineCounter(); + lineCounter.addNewLine(0); + const documents = parseAllDocuments(envelope.source, { + version: "1.2", + schema: "failsafe", + strict: true, + uniqueKeys: true, + stringKeys: true, + keepSourceTokens: true, + lineCounter, + }); + const findings: Finding[] = []; + const directiveMatch = /(?:^|\r?\n)%/u.exec(envelope.source); + const directiveOffset = + directiveMatch === null ? -1 : directiveMatch.index + directiveMatch[0].length - 1; + if (directiveOffset >= 0) + report( + findings, + file, + markdownLine(envelope.source, directiveOffset, envelope.baseLine), + "YAML directives are not accepted", + ); + if (documents.length !== 1 || documents[0]?.contents === null) + report(findings, file, 1, "frontmatter must contain exactly one nonempty YAML document"); + const document = documents[0]; + if (document === undefined) return { ok: false, findings: capMarkdownFindings(findings, file) }; + for (const diagnostic of [...document.errors, ...document.warnings]) + report( + findings, + file, + markdownLine(envelope.source, diagnostic.pos[0], envelope.baseLine), + diagnostic.message, + ); + if (document.directives.yaml.explicit === true && directiveOffset < 0) + report(findings, file, 1, "YAML directives are not accepted"); + const root = document.contents; + if (root === null || !isMap(root)) { + report(findings, file, 1, "frontmatter must be a mapping"); + return { ok: false, findings: capMarkdownFindings(findings, file) }; + } + inspectMarkdownNodes(root, envelope.source, envelope.baseLine, file, findings); + const data: Record = {}; + const names = new Set(); + let idLine = 1; + for (const pair of root.items) { + const line = markdownScalarLine(pair.key, envelope.source, envelope.baseLine); + if (!isMarkdownStringScalar(pair.key)) { + report(findings, file, line, "frontmatter keys must be string scalars"); + continue; + } + const name = pair.key.value; + if (names.has(name)) { + report(findings, file, line, `frontmatter key "${name}" is authored more than once`); + continue; + } + names.add(name); + if (!envelopeKeys.has(name)) { + report( + findings, + file, + line, + `frontmatter key "${name}" is not accepted`, + isReservedEnvelopeKey(name) + ? "extract/reserved-property" + : name === "title" + ? "extract/invalid-frontmatter" + : "extract/unrecognized-property", + ); + continue; + } + if (name === "relations") { + if (!isMap(pair.value)) { + report(findings, file, line, "relations must be a mapping"); + continue; + } + const relations: { + readonly type: string; + readonly target: string; + readonly claim: "declared"; + }[] = []; + const relationNames = new Set(); + for (const relation of pair.value.items) { + const relationLine = markdownScalarLine(relation.key, envelope.source, envelope.baseLine); + if (!isMarkdownStringScalar(relation.key) || !relationTypes.has(relation.key.value)) { + report(findings, file, relationLine, "relations contain an unsupported key"); + continue; + } + if (relationNames.has(relation.key.value)) { + report( + findings, + file, + relationLine, + `relation "${relation.key.value}" is authored more than once`, + ); + continue; + } + relationNames.add(relation.key.value); + for (const target of markdownRelationTargets( + relation.value, + envelope.source, + envelope.baseLine, + file, + findings, + )) + relations.push({ type: relation.key.value, target, claim: "declared" }); + } + data.relations = relations; + continue; + } + if (!isMarkdownStringScalar(pair.value)) { + report(findings, file, line, `frontmatter field "${name}" must be a string scalar`); + continue; + } + const value = pair.value.value; + if (name === "id") { + idLine = line; + try { + if (parseId(value).namespace !== "spec") + throw new Error("id must use the spec namespace"); + data.id = value; + } catch (error: unknown) { + report( + findings, + file, + line, + error instanceof Error ? error.message : "invalid id", + "extract/invalid-id", + ); + } + } else if (name === "kind" && !specKinds.has(value)) + report( + findings, + file, + line, + "kind is not a recognized spec kind", + "extract/non-static-envelope", + ); + else if (name === "altitude" && !specAltitudes.has(value)) + report( + findings, + file, + line, + "altitude is not a recognized spec altitude", + "extract/non-static-envelope", + ); + else if (name === "readiness" && !specReadiness.has(value)) + report( + findings, + file, + line, + "readiness is not a recognized spec readiness", + "extract/non-static-envelope", + ); + else data[name] = value; + } + for (const required of ["id", "kind", "altitude", "readiness", "relations"]) + if (!names.has(required)) + report(findings, file, 1, `frontmatter field "${required}" is missing`); + const result = capMarkdownFindings(findings, file); + const id = data.id; + return result.length === 0 && typeof id === "string" + ? { ok: true, frontmatter: { data, id, line: idLine }, findings: result } + : { ok: false, findings: result }; + } catch (error: unknown) { + return refusal( + file, + error instanceof Error + ? `frontmatter parser failed: ${error.message}` + : "frontmatter parser threw an unknown value", + ); + } +} diff --git a/src/extract/markdown-yaml-policy.ts b/src/extract/markdown-yaml-policy.ts index 2636c9c..c621c4c 100644 --- a/src/extract/markdown-yaml-policy.ts +++ b/src/extract/markdown-yaml-policy.ts @@ -7,8 +7,13 @@ import { parseId } from "../ids.js"; import type { Finding } from "../validate/contracts.js"; import { addMarkdownFinding, markdownFinding, markdownLine } from "./markdown-support.js"; -const nonStringPlainScalars = /^(?:[-+]?\d+(?:\.\d+)?|\.inf|\.nan|true|false|null|~)$/iu; +const nonStringPlainScalars = + /^(?:true|false|null|~|0o[0-7]+|0x[0-9a-f]+|[-+]?[0-9]+|[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)[eE][-+]?[0-9]+|[-+]?(?:\.[0-9]+|[0-9]+\.[0-9]*)|[-+]?\.inf|\.nan)$/iu; const reservedEnvelopeKeys = new Set([ + "implemented", + "has-verifier", + "hasVerifier", + "observed", "claim", "deliveryFacts", "nodeType", @@ -37,7 +42,7 @@ export function isMarkdownStringScalar( return ( isScalar(node) && typeof node.value === "string" && - !nonStringPlainScalars.test(node.source ?? "") + (node.type !== "PLAIN" || !nonStringPlainScalars.test(node.source ?? "")) ); } @@ -50,21 +55,27 @@ export function inspectMarkdownNodes( ): void { const pending: { readonly node: Node; readonly depth: number }[] = [{ node: root, depth: 1 }]; let count = 0; + let nodeLimitReported = false; + let depthLimitReported = false; while (pending.length > 0) { const current = pending.pop(); if (current === undefined) break; count += 1; const line = markdownScalarLine(current.node, source, baseLine); - if (count > 2_000) + if (count > 2_000 && !nodeLimitReported) { addMarkdownFinding( findings, markdownFinding(file, line, "frontmatter exceeds the 2,000 node limit"), ); - if (current.depth > 16) + nodeLimitReported = true; + } + if (current.depth > 16 && !depthLimitReported) { addMarkdownFinding( findings, markdownFinding(file, line, "frontmatter exceeds the depth limit of 16"), ); + depthLimitReported = true; + } if (isAlias(current.node)) addMarkdownFinding(findings, markdownFinding(file, line, "YAML aliases are not accepted")); if (current.node.tag !== undefined) diff --git a/src/extract/markdown.ts b/src/extract/markdown.ts index f89252e..24c9d40 100644 --- a/src/extract/markdown.ts +++ b/src/extract/markdown.ts @@ -1,22 +1,11 @@ -import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; -import { isMap, LineCounter, parseAllDocuments } from "yaml"; - -import { parseId } from "../ids.js"; -import { SPEC_ALTITUDES, SPEC_KINDS, SPEC_READINESS } from "../model/descriptors.js"; -import { SPEC_RELATION_TYPES } from "../model/relations.js"; -import type { Finding } from "../validate/contracts.js"; +import { codeAnchorId, ref } from "../ids.js"; +import { codeAnchor } from "../model/code-anchor.js"; import type { CarrierReification } from "./carrier.js"; import { parseMarkdownBody } from "./markdown-body.js"; import { readMarkdownEnvelope } from "./markdown-envelope.js"; -import { capMarkdownFindings, markdownFinding, markdownLine, report } from "./markdown-support.js"; +import { parseMarkdownFrontmatter as parseFrontmatter } from "./markdown-frontmatter.js"; +import { markdownFinding } from "./markdown-support.js"; import type { MarkdownBodyResult, MarkdownFrontmatterResult } from "./markdown-types.js"; -import { - inspectMarkdownNodes, - isReservedEnvelopeKey, - isMarkdownStringScalar, - markdownRelationTargets, - markdownScalarLine, -} from "./markdown-yaml-policy.js"; export type { MarkdownBody, @@ -25,16 +14,6 @@ export type { MarkdownFrontmatterResult, } from "./markdown-types.js"; -const relationTypes = new Set(SPEC_RELATION_TYPES); -const envelopeKeys = new Set(["id", "kind", "altitude", "readiness", "relations"]); -const specKinds = new Set(SPEC_KINDS); -const specAltitudes = new Set(SPEC_ALTITUDES); -const specReadiness = new Set(SPEC_READINESS); - -function refusal(file: string, message: string): MarkdownFrontmatterResult { - return { ok: false, findings: [markdownFinding(file, 1, message)] }; -} - export const envelopeContractAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.envelope-contract"), label: "parses the bounded Markdown frontmatter envelope", @@ -45,173 +24,7 @@ export function parseMarkdownFrontmatter( sourceText: string, file: string, ): MarkdownFrontmatterResult { - const envelope = readMarkdownEnvelope(sourceText); - if (typeof envelope === "string") return refusal(file, envelope); - try { - const documents = parseAllDocuments(envelope.source, { - version: "1.2", - schema: "failsafe", - strict: true, - uniqueKeys: true, - stringKeys: true, - keepSourceTokens: true, - lineCounter: new LineCounter(), - }); - const findings: Finding[] = []; - const directiveOffset = envelope.source.search(/(?:^|\r?\n)%/u); - if (directiveOffset >= 0) - report( - findings, - file, - markdownLine(envelope.source, directiveOffset, envelope.baseLine), - "YAML directives are not accepted", - ); - if (documents.length !== 1 || documents[0]?.contents === null) - report(findings, file, 1, "frontmatter must contain exactly one nonempty YAML document"); - const document = documents[0]; - if (document === undefined) return { ok: false, findings: capMarkdownFindings(findings, file) }; - for (const diagnostic of [...document.errors, ...document.warnings]) - report( - findings, - file, - markdownLine(envelope.source, diagnostic.pos[0], envelope.baseLine), - diagnostic.message, - ); - if (document.directives.yaml.explicit === true && directiveOffset < 0) - report(findings, file, 1, "YAML directives are not accepted"); - const root = document.contents; - if (root === null || !isMap(root)) return refusal(file, "frontmatter must be a mapping"); - inspectMarkdownNodes(root, envelope.source, envelope.baseLine, file, findings); - const data: Record = {}; - const names = new Set(); - let idLine = 1; - for (const pair of root.items) { - const line = markdownScalarLine(pair.key, envelope.source, envelope.baseLine); - if (!isMarkdownStringScalar(pair.key)) { - report(findings, file, line, "frontmatter keys must be string scalars"); - continue; - } - const name = pair.key.value; - if (names.has(name)) { - report(findings, file, line, `frontmatter key "${name}" is authored more than once`); - continue; - } - names.add(name); - if (!envelopeKeys.has(name)) { - report( - findings, - file, - line, - `frontmatter key "${name}" is not accepted`, - isReservedEnvelopeKey(name) - ? "extract/reserved-property" - : name === "title" - ? "extract/invalid-frontmatter" - : "extract/unrecognized-property", - ); - continue; - } - if (name === "relations") { - if (!isMap(pair.value)) { - report(findings, file, line, "relations must be a mapping"); - continue; - } - const relations: { - readonly type: string; - readonly target: string; - readonly claim: "declared"; - }[] = []; - const relationNames = new Set(); - for (const relation of pair.value.items) { - const relationLine = markdownScalarLine(relation.key, envelope.source, envelope.baseLine); - if (!isMarkdownStringScalar(relation.key) || !relationTypes.has(relation.key.value)) { - report(findings, file, relationLine, "relations contain an unsupported key"); - continue; - } - if (relationNames.has(relation.key.value)) { - report( - findings, - file, - relationLine, - `relation "${relation.key.value}" is authored more than once`, - ); - continue; - } - relationNames.add(relation.key.value); - for (const target of markdownRelationTargets( - relation.value, - envelope.source, - envelope.baseLine, - file, - findings, - )) - relations.push({ type: relation.key.value, target, claim: "declared" }); - } - data.relations = relations; - continue; - } - if (!isMarkdownStringScalar(pair.value)) { - report(findings, file, line, `frontmatter field "${name}" must be a string scalar`); - continue; - } - const value = pair.value.value; - if (name === "id") { - idLine = line; - try { - if (parseId(value).namespace !== "spec") - throw new Error("id must use the spec namespace"); - data.id = value; - } catch (error: unknown) { - report( - findings, - file, - line, - error instanceof Error ? error.message : "invalid id", - "extract/invalid-id", - ); - } - } else if (name === "kind" && !specKinds.has(value)) - report( - findings, - file, - line, - "kind is not a recognized spec kind", - "extract/non-static-envelope", - ); - else if (name === "altitude" && !specAltitudes.has(value)) - report( - findings, - file, - line, - "altitude is not a recognized spec altitude", - "extract/non-static-envelope", - ); - else if (name === "readiness" && !specReadiness.has(value)) - report( - findings, - file, - line, - "readiness is not a recognized spec readiness", - "extract/non-static-envelope", - ); - else data[name] = value; - } - for (const required of ["id", "kind", "altitude", "readiness", "relations"]) - if (!names.has(required)) - report(findings, file, 1, `frontmatter field "${required}" is missing`); - const result = capMarkdownFindings(findings, file, "extract/invalid-markdown-structure"); - const id = data.id; - return result.length === 0 && typeof id === "string" - ? { ok: true, frontmatter: { data, id, line: idLine }, findings: result } - : { ok: false, findings: result }; - } catch (error: unknown) { - return refusal( - file, - error instanceof Error - ? `frontmatter parser failed: ${error.message}` - : "frontmatter parser threw an unknown value", - ); - } + return parseFrontmatter(sourceText, file); } export const proseOwnershipAnchor = codeAnchor({ @@ -244,27 +57,41 @@ export const markdownParserAnchor = codeAnchor({ }); export function reifyMarkdownCarrier(sourceText: string, relativePath: string): CarrierReification { - const frontmatter = parseMarkdownFrontmatter(sourceText, relativePath); - if (!frontmatter.ok) return { specs: [], packs: [], findings: frontmatter.findings }; - const kind = frontmatter.frontmatter.data.kind; - if (typeof kind !== "string") + try { + const frontmatter = parseMarkdownFrontmatter(sourceText, relativePath); + if (!frontmatter.ok) return { specs: [], packs: [], findings: frontmatter.findings }; + const kind = frontmatter.frontmatter.data.kind; + if (typeof kind !== "string") + return { + specs: [], + packs: [], + findings: [markdownFinding(relativePath, 1, "frontmatter kind is unavailable")], + }; + const body = readMarkdownBody(sourceText, relativePath, kind); + if (!body.ok) return { specs: [], packs: [], findings: body.findings }; + return { + specs: [ + { + data: { ...frontmatter.frontmatter.data, ...body.body.data }, + id: frontmatter.frontmatter.id, + file: relativePath, + line: frontmatter.frontmatter.line, + }, + ], + packs: [], + findings: [], + }; + } catch (error: unknown) { return { specs: [], packs: [], - findings: [markdownFinding(relativePath, 1, "frontmatter kind is unavailable")], + findings: [ + markdownFinding( + relativePath, + 1, + `the carrier could not be reified: ${error instanceof Error ? error.message : "an unknown value was thrown"}`, + ), + ], }; - const body = readMarkdownBody(sourceText, relativePath, kind); - if (!body.ok) return { specs: [], packs: [], findings: body.findings }; - return { - specs: [ - { - data: { ...frontmatter.frontmatter.data, ...body.body.data }, - id: frontmatter.frontmatter.id, - file: relativePath, - line: frontmatter.frontmatter.line, - }, - ], - packs: [], - findings: [], - }; + } } diff --git a/src/extract/protocol-bindings.ts b/src/extract/protocol-bindings.ts new file mode 100644 index 0000000..d61ec53 --- /dev/null +++ b/src/extract/protocol-bindings.ts @@ -0,0 +1,162 @@ +import { existsSync, realpathSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { SourceFile } from "ts-morph"; + +/** The one public package specifier whose imports bind Protocol authoring builders. */ +export const PROTOCOL_MODULE_SPECIFIER = "@libar-dev/software-delivery-protocol"; + +export interface ProtocolBindings { + /** Local name → exported builder name, from named imports. */ + readonly named: ReadonlyMap; + /** Locals bound by namespace or default imports. */ + readonly namespaceLocals: ReadonlySet; +} + +export interface ProtocolBindingScope { + readonly importerPath?: string; + readonly trustedRelativeBuilderModules: ReadonlySet; +} + +const packageOnlyScope: ProtocolBindingScope = { + trustedRelativeBuilderModules: new Set(), +}; + +function nearestPackageRoot(modulePath: string): string | undefined { + let current = dirname(modulePath); + + for (;;) { + if (existsSync(join(current, "package.json"))) { + return current; + } + + const parent = dirname(current); + + if (parent === current) { + return undefined; + } + + current = parent; + } +} + +function canonicalPath(path: string): string | undefined { + try { + return realpathSync(path); + } catch { + return undefined; + } +} + +function trustedBuilderModules(): ReadonlySet { + const moduleUrl = import.meta.url; + + // tsup's CommonJS barrel has no import.meta URL. Relative bindings are a source/ESM + // self-hosting affordance, so that surface fails closed while package imports stay trusted. + if (typeof moduleUrl !== "string") { + return new Set(); + } + + const packageRoot = nearestPackageRoot(fileURLToPath(moduleUrl)); + + if (packageRoot === undefined) { + return new Set(); + } + + // The tsup bundle ships no per-module dist files, so only the source-checkout modules + // can ever canonicalize here; relative trust is a source/ESM self-hosting affordance. + const candidates = [ + join(packageRoot, "src", "ids.ts"), + join(packageRoot, "src", "model", "code-anchor.ts"), + ]; + + return new Set(candidates.flatMap((candidate) => canonicalPath(candidate) ?? [])); +} + +const runtimeBuilderModules = trustedBuilderModules(); + +export function protocolBindingScopeFor(importerPath: string): ProtocolBindingScope { + return { + importerPath, + trustedRelativeBuilderModules: runtimeBuilderModules, + }; +} + +function relativeImportCandidates(importerPath: string, specifier: string): readonly string[] { + const resolved = resolve(dirname(importerPath), specifier); + + return specifier.endsWith(".js") + ? [resolved, `${resolved.slice(0, -3)}.ts`, `${resolved.slice(0, -3)}.tsx`] + : [resolved]; +} + +export function isProtocolBuilderModuleSpecifier( + specifier: string, + scope: ProtocolBindingScope = packageOnlyScope, +): boolean { + if (specifier === PROTOCOL_MODULE_SPECIFIER) { + return true; + } + + if ( + scope.importerPath === undefined || + (!specifier.startsWith("./") && !specifier.startsWith("../")) + ) { + return false; + } + + return relativeImportCandidates(scope.importerPath, specifier).some((candidate) => { + const canonical = canonicalPath(candidate); + return canonical !== undefined && scope.trustedRelativeBuilderModules.has(canonical); + }); +} + +export function hasProtocolBuilderImport( + sourceText: string, + scope: ProtocolBindingScope = packageOnlyScope, +): boolean { + if (sourceText.includes(PROTOCOL_MODULE_SPECIFIER)) { + return true; + } + + return ( + scope.importerPath !== undefined && + scope.trustedRelativeBuilderModules.size > 0 && + /\bfrom\s+["']\.\.?\//u.test(sourceText) + ); +} + +export function collectProtocolBindings( + sourceFile: SourceFile, + scope: ProtocolBindingScope = packageOnlyScope, +): ProtocolBindings { + const named = new Map(); + const namespaceLocals = new Set(); + + for (const importDeclaration of sourceFile.getImportDeclarations()) { + if (!isProtocolBuilderModuleSpecifier(importDeclaration.getModuleSpecifierValue(), scope)) { + continue; + } + + for (const namedImport of importDeclaration.getNamedImports()) { + const exportedName = namedImport.getName(); + const localName = namedImport.getAliasNode()?.getText() ?? exportedName; + named.set(localName, exportedName); + } + + const namespaceImport = importDeclaration.getNamespaceImport(); + + if (namespaceImport !== undefined) { + namespaceLocals.add(namespaceImport.getText()); + } + + const defaultImport = importDeclaration.getDefaultImport(); + + if (defaultImport !== undefined) { + namespaceLocals.add(defaultImport.getText()); + } + } + + return { named, namespaceLocals }; +} diff --git a/src/extract/reify.ts b/src/extract/reify.ts index 6b7034b..fc96dc9 100644 --- a/src/extract/reify.ts +++ b/src/extract/reify.ts @@ -13,6 +13,9 @@ import { SPEC_ALTITUDES, SPEC_KINDS, SPEC_READINESS } from "../model/descriptors import { SPEC_RELATION_TYPES } from "../model/relations.js"; import { SPEC_SECTION_NAMES } from "../model/sections.js"; import type { Finding, Severity } from "../validate/contracts.js"; +import { collectProtocolBindings, isProtocolBuilderModuleSpecifier } from "./protocol-bindings.js"; +import type { ProtocolBindings, ProtocolBindingScope } from "./protocol-bindings.js"; +import { setOwn } from "./set-own.js"; /** * Static reification (`04` §1): a spec file is a JSON file that TypeScript happens to validate @@ -25,8 +28,6 @@ import type { Finding, Severity } from "../validate/contracts.js"; * reached any other way (`require`, a re-aliased local, an element access) is out of contract and * stays out of the graph. */ -export const PROTOCOL_MODULE_SPECIFIER = "@libar-dev/software-delivery-protocol"; - /** * The extraction finding ids, pinned. Two tiers (`03` §2), covering both authored surfaces (spec * files and anchor constants): envelope failures (`non-static-envelope` · `invalid-id` · @@ -96,6 +97,8 @@ const SPEC_SECTION_NAME_SET = new Set(SPEC_SECTION_NAMES); */ const RESERVED_DERIVED_PROPERTIES = new Set([ ...deliveryFactNames, + // camel `hasVerifier` joins the kebab delivery facts: every delivery-fact spelling is reserved in both carriers (R-6 parity). + "hasVerifier", "deliveryFacts", "claim", "nodeType", @@ -168,10 +171,10 @@ function staticFailure(node: Node, path: string, reason: string): StaticResult { } function describeNonStatic(node: Node): string { - return `${node.getKindName()} is outside the static value grammar (string/number/boolean literals, array and fresh object literals; \`as const\` and parentheses are transparent; id builders unwrap in id slots only)`; + return `${node.getKindName()} is outside the static value grammar (string/number/boolean literals, array and fresh object literals; TypeScript assertions and parentheses are transparent; id builders unwrap in id slots only)`; } -/** `as const` and parentheses are transparent; every other wrapper is non-static. */ +/** TypeScript assertions and parentheses are transparent; every other wrapper is non-static. */ export function unwrapTransparent(node: Node): Node { let current = node; @@ -181,7 +184,7 @@ export function unwrapTransparent(node: Node): Node { continue; } - if (Node.isAsExpression(current) && current.getTypeNode()?.getText() === "const") { + if (Node.isAsExpression(current)) { current = current.getExpression(); continue; } @@ -190,48 +193,6 @@ export function unwrapTransparent(node: Node): Node { } } -export interface ProtocolBindings { - /** Local name → exported builder name, from named imports. */ - readonly named: ReadonlyMap; - /** - * Locals bound by `import * as ns` — every protocol builder is reachable as a property — and by - * a default import (the package ships no default export, but an interop consumer can still - * author through one; treating it the same keeps the binding from silently falling out, L2). - */ - readonly namespaceLocals: ReadonlySet; -} - -export function collectProtocolBindings(sourceFile: SourceFile): ProtocolBindings { - const named = new Map(); - const namespaceLocals = new Set(); - - for (const importDeclaration of sourceFile.getImportDeclarations()) { - if (importDeclaration.getModuleSpecifierValue() !== PROTOCOL_MODULE_SPECIFIER) { - continue; - } - - for (const namedImport of importDeclaration.getNamedImports()) { - const exportedName = namedImport.getName(); - const localName = namedImport.getAliasNode()?.getText() ?? exportedName; - named.set(localName, exportedName); - } - - const namespaceImport = importDeclaration.getNamespaceImport(); - - if (namespaceImport !== undefined) { - namespaceLocals.add(namespaceImport.getText()); - } - - const defaultImport = importDeclaration.getDefaultImport(); - - if (defaultImport !== undefined) { - namespaceLocals.add(defaultImport.getText()); - } - } - - return { named, namespaceLocals }; -} - export interface ResolvedBuilderCall { readonly call: CallExpression; readonly builder: string; @@ -605,7 +566,7 @@ function reifyStaticObject( return result; } - value[name] = result.value; + setOwn(value, name, result.value); } return { ok: true, value }; @@ -763,7 +724,7 @@ function reifyObjectLossy( if (Node.isObjectLiteralExpression(inner)) { const nested = reifyObjectLossy(inner, propertyPath, bindings); - value[name] = nested.value; + setOwn(value, name, nested.value); drops.push(...nested.drops); continue; } @@ -771,7 +732,7 @@ function reifyObjectLossy( const result = reifyStaticValue(initializer, propertyPath, bindings); if (result.ok) { - value[name] = result.value; + setOwn(value, name, result.value); continue; } @@ -867,7 +828,7 @@ function sanitizeSectionValue(node: Node, value: unknown, path: string): Section } const child = sanitizeSectionValue(initializer, value[name], propertyPath); - sanitized[name] = child.value; + setOwn(sanitized, name, child.value); issues.push(...child.issues); } @@ -1666,7 +1627,7 @@ function unrecognizedStatement(file: string, line: number, message: string): Fin return createExtractFinding( extractFindingIds.unrecognizedStatement, "warning", - `${message}; the statement is ignored (the recognized set: imports from "${PROTOCOL_MODULE_SPECIFIER}" and const declarations initialized with spec(…)/pack(…))`, + `${message}; the statement is ignored (the recognized set: imports bound to Protocol builder modules and const declarations initialized with spec(…)/pack(…))`, file, line, ); @@ -1678,15 +1639,19 @@ function unrecognizedStatement(file: string, line: number, message: string): Fin * defines no hard-error class for foreign statements (the `sdp/spec-static` lint stays future * work). */ -export function reifySourceFile(sourceFile: SourceFile, relativePath: string): FileReification { - const bindings = collectProtocolBindings(sourceFile); +export function reifySourceFile( + sourceFile: SourceFile, + relativePath: string, + bindingScope?: ProtocolBindingScope, +): FileReification { + const bindings = collectProtocolBindings(sourceFile, bindingScope); const specs: ReifiedSpec[] = []; const packs: ReifiedPack[] = []; const findings: Finding[] = []; for (const statement of sourceFile.getStatements()) { if (Node.isImportDeclaration(statement)) { - if (statement.getModuleSpecifierValue() === PROTOCOL_MODULE_SPECIFIER) { + if (isProtocolBuilderModuleSpecifier(statement.getModuleSpecifierValue(), bindingScope)) { continue; } diff --git a/src/extract/serialize.ts b/src/extract/serialize.ts index 5b7c8f6..476ebae 100644 --- a/src/extract/serialize.ts +++ b/src/extract/serialize.ts @@ -9,6 +9,7 @@ import type { SpecSections, VerificationSection, } from "../model/sections.js"; +import { setOwn } from "./set-own.js"; /** * Every output byte is owned here, so no `ts-morph` upgrade can change them silently: nodes sorted @@ -108,7 +109,7 @@ function canonicalDynamicSection( for (const key of Object.keys(section) .filter((key) => key !== "description") .sort(compareCodeUnits)) { - result[key] = section[key]; + setOwn(result, key, section[key]); } return result; diff --git a/src/extract/set-own.ts b/src/extract/set-own.ts new file mode 100644 index 0000000..76aaa44 --- /dev/null +++ b/src/extract/set-own.ts @@ -0,0 +1,9 @@ +/** Assign an author-controlled key as a genuine own property. Plain assignment to a plain object literal hits the inherited `__proto__` setter — silently dropping primitives, and replacing the prototype with object values — so authored content would vanish from the graph with zero findings, fail-open under the honesty law. defineProperty bypasses the setter and keeps plain-object semantics (JSON, spread, Object.keys, toStrictEqual). */ +export function setOwn(target: Record, key: string, value: unknown): void { + Object.defineProperty(target, key, { + value, + enumerable: true, + writable: true, + configurable: true, + }); +} diff --git a/src/graph/schema.ts b/src/graph/schema.ts index b1f82d9..8fe9980 100644 --- a/src/graph/schema.ts +++ b/src/graph/schema.ts @@ -1,14 +1,30 @@ +import { codeAnchorId, ref } from "../ids.js"; +import { codeAnchor } from "../model/code-anchor.js"; import type { SpecAltitude, SpecKind, SpecReadiness } from "../model/descriptors.js"; import type { SpecSections } from "../model/sections.js"; export const schemaVersion = "0.4.0" as const; +const schemaVersionAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.schema-version"), + label: "declares the graph schema version", + satisfies: ref("spec:extraction.schema-versioning"), +}); +void schemaVersionAnchor; + export const graphNodeTypes = ["Primitive", "Pack", "Anchor", "CodeNode"] as const; export type GraphNodeType = (typeof graphNodeTypes)[number]; export const graphClaims = ["declared", "anchored", "inferred"] as const; export type GraphClaim = (typeof graphClaims)[number]; +const graphClaimsAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.graph-claims"), + label: "declares the graph claim taxonomy", + satisfies: ref("spec:extraction.claim-taxonomy"), +}); +void graphClaimsAnchor; + export const deliveryFactNames = ["implemented", "has-verifier", "observed"] as const; export type DeliveryFactName = (typeof deliveryFactNames)[number]; diff --git a/src/ids.ts b/src/ids.ts index e28f928..9e111b6 100644 --- a/src/ids.ts +++ b/src/ids.ts @@ -1,3 +1,5 @@ +import { codeAnchor } from "./model/code-anchor.js"; + type Brand = string & { readonly __brand: TBrand; }; @@ -128,6 +130,16 @@ export function parseId(value: string): IdParts { return validateIdShape(value); } +// R-27 bootstrap seam: these two casts stay because same-module local builder calls are +// intentionally not recognized as imported Protocol bindings (plan 19, execution step 2). +const stableIdsAnchor = codeAnchor({ + id: "impl:protocol.stable-ids" as CodeAnchorId, + label: "stable ID grammar parser", + satisfies: "spec:model.stable-ids" as SpecId, +}); + +void stableIdsAnchor; + export function formatId(parts: IdParts): string { return parts.subpath === undefined ? `${parts.namespace}:${parts.path}` diff --git a/src/import/data-access.ts b/src/import/data-access.ts new file mode 100644 index 0000000..4758e96 --- /dev/null +++ b/src/import/data-access.ts @@ -0,0 +1,43 @@ +export type ImportData = Record; + +export function isImportData(value: unknown): value is ImportData { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function importData(value: unknown): ImportData | undefined { + return isImportData(value) ? value : undefined; +} + +export function importText(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function importTexts(value: unknown): readonly string[] { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((entry) => { + const text = importText(entry); + return text === undefined ? [] : [text]; + }); +} + +function isRelation(value: unknown): value is { readonly type: string; readonly target: string } { + const candidate = importData(value); + return ( + candidate !== undefined && + importText(candidate.type) !== undefined && + importText(candidate.target) !== undefined + ); +} + +export function targetsForRelationType(relations: unknown, type: string): readonly string[] { + if (!Array.isArray(relations)) { + return []; + } + + return relations.flatMap((relation) => + isRelation(relation) && relation.type === type ? [relation.target] : [], + ); +} diff --git a/src/import/emit-markdown.ts b/src/import/emit-markdown.ts new file mode 100644 index 0000000..72ac9de --- /dev/null +++ b/src/import/emit-markdown.ts @@ -0,0 +1,246 @@ +import type { ReifiedSpec } from "../extract/reify.js"; +import { importData, importText, importTexts, targetsForRelationType } from "./data-access.js"; +import type { ImportData } from "./data-access.js"; +import { assertMarkdownEmissionFidelity } from "./markdown-fidelity.js"; +import { MarkdownEmissionError } from "./markdown-fidelity.js"; + +const relationTypes = [ + "refines", + "dependsOn", + "constrainedBy", + "decidedBy", + "verifies", + "supersedes", +] as const; +const verificationModes = ["manual", "reviewed", "contract", "executable"] as const; +const camelKey = /^[a-z][A-Za-z0-9]*$/u; + +function section( + heading: string, + description: unknown, + items: readonly string[], +): string | undefined { + const prose = importText(description); + if (prose === undefined && items.length === 0) return undefined; + return [`## ${heading}`, prose, items.length > 0 ? items.join("\n") : undefined] + .filter((part): part is string => part !== undefined) + .join("\n\n"); +} + +function fence(kind: "gwt" | "gwt-vocabulary", value: unknown): string | undefined { + const example = importData(value); + if (example === undefined) return undefined; + const given = importTexts(example.given); + const when = importTexts(example.when); + const outcome = importTexts(example.then); + const action = when[0]; + if (given.length === 0 || action === undefined || outcome.length === 0) return undefined; + const steps = [ + ...given.map((step, index) => `${index === 0 ? "Given" : "And"} ${step}`), + `When ${action}`, + ...outcome.map((step, index) => `${index === 0 ? "Then" : "And"} ${step}`), + ]; + return ["```" + kind, ...steps, "```"].join("\n"); +} + +function intentSection( + intent: ImportData | undefined, + example: string | undefined, +): string | undefined { + const fields = ["actor", "problem", "outcome", "value"].flatMap((name) => { + const value = importText(intent?.[name]); + return value === undefined ? [] : [`- ${name}: ${value}`]; + }); + const repeated = [ + ...importTexts(intent?.risks).map((value) => `- risk: ${value}`), + ...importTexts(intent?.assumptions).map((value) => `- assumption: ${value}`), + ]; + const questions = Array.isArray(intent?.openQuestions) + ? intent.openQuestions.flatMap((question) => { + const entry = importData(question); + const value = importText(entry?.question); + return value === undefined || typeof entry?.blocking !== "boolean" + ? [] + : [`- [${entry.blocking ? "blocking" : "non-blocking"}] ${value}`]; + }) + : []; + const fieldBlock = [...fields, ...repeated].join("\n"); + const questionBlock = + questions.length === 0 ? undefined : ["### Open questions", "", ...questions].join("\n"); + const blocks = [fieldBlock.length === 0 ? undefined : fieldBlock, questionBlock, example].filter( + (block): block is string => block !== undefined, + ); + return section("Intent", intent?.description, blocks.length === 0 ? [] : [blocks.join("\n\n")]); +} + +function behaviorSection(data: ImportData, behavior: ImportData): string | undefined { + const rules = importTexts(behavior.rules); + const flows = importTexts(behavior.flows); + const kind = importText(data.kind); + const ownsExampleSpace = fence("gwt-vocabulary", behavior.exampleSpace) !== undefined; + if (kind === "behavior" && ownsExampleSpace && rules.length === 0 && flows.length === 0) + return undefined; + if (kind === "behavior") + return section("Behavior", behavior.description, [ + ...rules.map((value) => `- rule: ${value}`), + ...flows.map((value) => `- flow: ${value}`), + ]); + if (kind === "rule") + return section( + "Rule", + behavior.description, + rules.map((value) => `- ${value}`), + ); + if (kind === "workflow") + return section("Workflow", behavior.description, [ + ...flows.map((value) => `- ${value}`), + ...rules.map((value) => `- rule: ${value}`), + ]); + if (kind === "contract") + return section( + "Contract", + behavior.description, + rules.map((value) => `- ${value}`), + ); + return undefined; +} + +function exampleSpaceSection( + behavior: ImportData, + hasPrimaryBehavior: boolean, +): string | undefined { + const vocabulary = fence("gwt-vocabulary", behavior.exampleSpace); + return vocabulary === undefined + ? undefined + : section("Example space", hasPrimaryBehavior ? undefined : behavior.description, [vocabulary]); +} + +function constraintsSection(value: unknown): string | undefined { + const constraint = Array.isArray(value) ? importData(value[0]) : undefined; + const statement = importText(constraint?.statement); + if (statement === undefined) return undefined; + const optional = ["flavor", "target", "measurableBy"].flatMap((name) => { + const field = importText(constraint?.[name]); + return field === undefined ? [] : [`- ${name}: ${field}`]; + }); + return section("Constraints", undefined, [`- statement: ${statement}`, ...optional]); +} + +function modelSection(model: ImportData | undefined): string | undefined { + const terms = importData(model?.terms); + const items = + terms === undefined + ? [] + : Object.entries(terms).flatMap(([term, definition]) => { + const value = importText(definition); + return value === undefined ? [] : [`- **${term}** — ${value}`]; + }); + return section("Model", model?.description, items); +} + +function openSection(heading: "Design" | "UI", value: ImportData | undefined): string | undefined { + const items = + value === undefined + ? [] + : Object.entries(value).flatMap(([key, field]) => { + const content = importText(field); + return key === "description" || !camelKey.test(key) || content === undefined + ? [] + : [`- ${key}: ${content}`]; + }); + return section(heading, value?.description, items); +} + +function decisionSection(decision: ImportData | undefined): string | undefined { + const singles = ["context", "decision"].flatMap((name) => { + const value = importText(decision?.[name]); + return value === undefined ? [] : [`- ${name}: ${value}`]; + }); + const repeated = [ + ...importTexts(decision?.rationale).map((value) => `- rationale: ${value}`), + ...importTexts(decision?.alternatives).map((value) => `- alternative: ${value}`), + ...importTexts(decision?.consequences).map((value) => `- consequence: ${value}`), + ]; + return section("Decision", decision?.description, [...singles, ...repeated]); +} + +function verificationSection(verification: ImportData | undefined): string | undefined { + const mode = importText(verification?.mode); + if (mode === undefined || !verificationModes.some((candidate) => candidate === mode)) + return undefined; + return section( + `Verification — ${mode}`, + verification?.description, + importTexts(verification?.criteria).map((value) => `- ${value}`), + ); +} + +export function emitMarkdownSpec(reified: ReifiedSpec): string { + const data = reified.data; + const title = importText(data.title); + + if (title === undefined) { + throw new MarkdownEmissionError("first divergent path: title (Markdown requires an H1 title)"); + } + + if (title.includes("\n") || title.includes("\r")) { + throw new MarkdownEmissionError( + "first divergent path: title (multi-line titles are not representable)", + ); + } + + const relationKeys = new Set(); + + for (const relation of Array.isArray(data.relations) ? data.relations : []) { + const value = importData(relation); + const key = `${String(value?.type)}\u0000${String(value?.target)}`; + + if (relationKeys.has(key)) { + throw new MarkdownEmissionError("first divergent path: relations (duplicate relation entry)"); + } + + relationKeys.add(key); + } + const relationLines = relationTypes.flatMap((type) => { + const targets = targetsForRelationType(data.relations, type); + if (targets.length === 0) return []; + const target = targets[0]; + return targets.length === 1 && target !== undefined + ? [` ${type}: ${target}`] + : [` ${type}:`, ...targets.map((target) => ` - ${target}`)]; + }); + const envelopeRelations = + relationLines.length === 0 ? ["relations: {}"] : ["relations:", ...relationLines]; + const envelope = [ + "---", + `id: ${String(data.id)}`, + `kind: ${String(data.kind)}`, + `altitude: ${String(data.altitude)}`, + `readiness: ${String(data.readiness)}`, + ...envelopeRelations, + "---", + `# ${title}`, + ].join("\n"); + const behavior = importData(data.behavior); + const examples = Array.isArray(behavior?.examples) ? behavior.examples : []; + const example = importText(data.kind) === "example" ? fence("gwt", examples[0]) : undefined; + const primaryBehavior = behavior === undefined ? undefined : behaviorSection(data, behavior); + const sections = [ + importText(data.narrative), + intentSection(importData(data.intent), example), + primaryBehavior, + behavior === undefined + ? undefined + : exampleSpaceSection(behavior, primaryBehavior !== undefined), + constraintsSection(data.constraints), + modelSection(importData(data.model)), + openSection("Design", importData(data.design)), + decisionSection(importData(data.decision)), + verificationSection(importData(data.verification)), + openSection("UI", importData(data.ui)), + ].filter((value): value is string => value !== undefined); + const document = + sections.length === 0 ? `${envelope}\n` : `${envelope}\n\n${sections.join("\n\n")}\n`; + assertMarkdownEmissionFidelity(reified, document); + return document; +} diff --git a/src/import/import.ts b/src/import/import.ts new file mode 100644 index 0000000..9a2208a --- /dev/null +++ b/src/import/import.ts @@ -0,0 +1,130 @@ +import { reifyTypeScriptCarrier } from "../extract/carrier.js"; +import { emitMarkdownSpec } from "./emit-markdown.js"; +import { MarkdownEmissionError } from "./markdown-fidelity.js"; +import type { Finding } from "../validate/contracts.js"; + +export const importFindingIds = { + refusal: "import/refusal", + packUnsupported: "import/pack-unsupported", + targetExists: "import/target-exists", + unsupportedConstruct: "import/unsupported-construct", + invalidSourcePath: "import/invalid-source-path", + noSources: "import/no-sources", + empty: "import/empty", +} as const; + +export interface ImportResult { + readonly emitted?: { + readonly path: string; + readonly content: string; + }; + readonly findings: readonly Finding[]; +} + +function importFinding( + validatorId: (typeof importFindingIds)[keyof typeof importFindingIds], + message: string, + file: string, +): Finding { + return { + validatorId, + family: "conformance", + severity: "error", + message, + file, + }; +} + +export function importTypeScriptSpec(sourceText: string, relativePath: string): ImportResult { + if (!relativePath.endsWith(".sdp.ts")) { + return { + findings: [ + importFinding( + importFindingIds.invalidSourcePath, + "the TypeScript import source must end with .sdp.ts", + relativePath, + ), + ], + }; + } + + // No binding scope on purpose: imported carriers must use the public package specifier; + // relative builder imports are a source-checkout affordance and refuse here (fail-closed). + const reification = reifyTypeScriptCarrier(sourceText, relativePath); + + if (reification.findings.length > 0) { + return { + findings: [ + ...reification.findings, + importFinding(importFindingIds.refusal, "the TypeScript carrier was refused", relativePath), + ], + }; + } + + const packFindings = + reification.packs.length === 0 + ? [] + : [ + importFinding( + importFindingIds.packUnsupported, + "the carrier contains a Pack; move the Spec to its own TypeScript carrier before importing because Packs remain TypeScript-authored", + relativePath, + ), + ]; + + if (reification.specs.length > 1) { + return { + findings: [ + ...packFindings, + importFinding( + importFindingIds.unsupportedConstruct, + "the TypeScript carrier reifies more than one Spec", + relativePath, + ), + ], + }; + } + + const spec = reification.specs[0]; + if (spec === undefined) { + return { + findings: + packFindings.length > 0 + ? packFindings + : [ + importFinding( + importFindingIds.empty, + "the TypeScript carrier contains no Specs or Packs", + relativePath, + ), + ], + }; + } + + if (packFindings.length > 0) { + return { findings: packFindings }; + } + + try { + return { + emitted: { + path: relativePath.replace(/\.sdp\.ts$/u, ".sdp.md"), + content: emitMarkdownSpec(spec), + }, + findings: [], + }; + } catch (error) { + if (error instanceof MarkdownEmissionError) { + return { + findings: [ + importFinding( + importFindingIds.unsupportedConstruct, + `the TypeScript carrier cannot emit faithful Markdown: ${error.reason}`, + relativePath, + ), + ], + }; + } + throw error; + } +} diff --git a/src/import/markdown-fidelity.ts b/src/import/markdown-fidelity.ts new file mode 100644 index 0000000..7f95c19 --- /dev/null +++ b/src/import/markdown-fidelity.ts @@ -0,0 +1,92 @@ +import { isDeepStrictEqual } from "node:util"; + +import { reifyMarkdownCarrier } from "../extract/markdown.js"; +import type { ReifiedSpec } from "../extract/reify.js"; + +export class MarkdownEmissionError extends Error { + constructor(readonly reason: string) { + super(reason); + this.name = "MarkdownEmissionError"; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isUnknownArray(value: unknown): value is readonly unknown[] { + return Array.isArray(value); +} + +function relationKey(value: unknown): string { + return isRecord(value) ? `${String(value.type)}\u0000${String(value.target)}` : ""; +} + +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function normalizeRelations(data: Record): Record { + if (!isUnknownArray(data.relations)) return { ...data, relations: [] }; + return { + ...data, + relations: [...data.relations].sort((left, right) => + compareCodeUnits(relationKey(left), relationKey(right)), + ), + }; +} + +function firstDivergentPath(left: unknown, right: unknown, path = ""): string | undefined { + if (isDeepStrictEqual(left, right)) { + return undefined; + } + + if (isUnknownArray(left) && isUnknownArray(right)) { + const length = Math.max(left.length, right.length); + + for (let index = 0; index < length; index += 1) { + const divergent = firstDivergentPath(left[index], right[index], `${path}[${String(index)}]`); + + if (divergent !== undefined) { + return divergent; + } + } + } + + if (isRecord(left) && isRecord(right)) { + const keys = [...new Set([...Object.keys(left), ...Object.keys(right)])].sort(compareCodeUnits); + + for (const key of keys) { + const childPath = path.length === 0 ? key : `${path}.${key}`; + const divergent = firstDivergentPath(left[key], right[key], childPath); + + if (divergent !== undefined) { + return divergent; + } + } + } + + return path.length === 0 ? "document" : path; +} + +export function assertMarkdownEmissionFidelity(reified: ReifiedSpec, document: string): void { + const result = reifyMarkdownCarrier(document, reified.file.replace(/\.sdp\.ts$/u, ".sdp.md")); + const emitted = result.specs[0]; + + if (result.findings.length > 0 || emitted === undefined) { + throw new MarkdownEmissionError( + `first divergent path: document (the emitted Markdown is not accepted by the ruled grammar: ${result.findings[0]?.message ?? "no Spec was reified"})`, + ); + } + + const divergent = firstDivergentPath( + normalizeRelations(reified.data), + normalizeRelations(emitted.data), + ); + + if (divergent !== undefined) { + throw new MarkdownEmissionError( + `first divergent path: ${divergent} (the emitted Markdown does not preserve the authored Spec data)`, + ); + } +} diff --git a/src/index.ts b/src/index.ts index 174efb1..11ee4e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,10 @@ export { reifyTypeScriptCarrier } from "./extract/carrier.js"; export { reifyMarkdownCarrier } from "./extract/markdown.js"; export type { CarrierReification, CarrierReifier } from "./extract/carrier.js"; export { deriveGraph } from "./extract/derive.js"; +export { emitMarkdownSpec } from "./import/emit-markdown.js"; +export { MarkdownEmissionError } from "./import/markdown-fidelity.js"; +export { importFindingIds, importTypeScriptSpec } from "./import/import.js"; +export type { ImportResult } from "./import/import.js"; export type { ReifiedAnchor } from "./extract/anchors.js"; export type { ReifiedPack, ReifiedSpec } from "./extract/reify.js"; export * from "./notation/slots.js"; diff --git a/src/model/anchors.ts b/src/model/anchors.ts index cc4d54d..032bd32 100644 --- a/src/model/anchors.ts +++ b/src/model/anchors.ts @@ -1,3 +1,6 @@ +import { codeAnchor as createCodeAnchor } from "./code-anchor.js"; + +import { codeAnchorId, ref } from "../ids.js"; import type { CodeAnchorId, OracleAnchorId, SpecId, TestAnchorId } from "../ids.js"; /** @@ -43,11 +46,7 @@ export interface SpecOracleAnchor { export type Anchor = CodeAnchor | SpecTestAnchor | SpecOracleAnchor; -export function codeAnchor(anchor: CodeAnchor): CodeAnchor { - return { - ...anchor, - }; -} +export { codeAnchor } from "./code-anchor.js"; export function specTest(anchor: SpecTestAnchor): SpecTestAnchor { return { @@ -60,3 +59,11 @@ export function specOracle(anchor: SpecOracleAnchor): SpecOracleAnchor { ...anchor, }; } + +const anchorModelAnchor = createCodeAnchor({ + id: codeAnchorId("impl:protocol.anchor-model"), + label: "binding-only anchor model builders", + satisfies: ref("spec:model.anchors"), +}); + +void anchorModelAnchor; diff --git a/src/model/code-anchor.ts b/src/model/code-anchor.ts new file mode 100644 index 0000000..50f933c --- /dev/null +++ b/src/model/code-anchor.ts @@ -0,0 +1,7 @@ +import type { CodeAnchor } from "./anchors.js"; + +export function codeAnchor(anchor: CodeAnchor): CodeAnchor { + return { + ...anchor, + }; +} diff --git a/src/model/descriptors.ts b/src/model/descriptors.ts index ac80537..9e0b36d 100644 --- a/src/model/descriptors.ts +++ b/src/model/descriptors.ts @@ -1,3 +1,6 @@ +import { codeAnchor } from "./code-anchor.js"; +import { codeAnchorId, ref } from "../ids.js"; + export const SPEC_KINDS = [ "behavior", "workflow", @@ -31,3 +34,11 @@ export const SPEC_KIND_DISPLAY_LABELS = { } as const satisfies Record; export type SpecKindDisplayLabel = (typeof SPEC_KIND_DISPLAY_LABELS)[SpecKind]; + +const specDescriptorsAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.spec-descriptors"), + label: "Spec kind, altitude, and readiness coordinates", + satisfies: ref("spec:model.core-model"), +}); + +void specDescriptorsAnchor; diff --git a/src/model/pack.ts b/src/model/pack.ts index 35a249d..73ba85c 100644 --- a/src/model/pack.ts +++ b/src/model/pack.ts @@ -1,3 +1,6 @@ +import { codeAnchor } from "./code-anchor.js"; + +import { codeAnchorId, ref } from "../ids.js"; import type { PackId, SpecId } from "../ids.js"; export interface Pack { @@ -15,3 +18,11 @@ export function pack(definition: Pack): Pack { modelRefs: definition.modelRefs === undefined ? undefined : [...definition.modelRefs], }; } + +const packAggregateAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.pack-aggregate"), + label: "Pack aggregate and model references", + satisfies: ref("spec:model.pack-aggregate"), +}); + +void packAggregateAnchor; diff --git a/src/model/relations.ts b/src/model/relations.ts index 8f78f7f..e7cd3c9 100644 --- a/src/model/relations.ts +++ b/src/model/relations.ts @@ -1,3 +1,6 @@ +import { codeAnchor } from "./code-anchor.js"; + +import { codeAnchorId, ref } from "../ids.js"; import type { SpecId } from "../ids.js"; export const SPEC_RELATION_TYPES = [ @@ -56,3 +59,11 @@ export function verifies(target: SpecId): SpecRelation<"verifies"> { export function supersedes(target: SpecId): SpecRelation<"supersedes"> { return declaredRelation("supersedes", target); } + +const specRelationsAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.spec-relations"), + label: "declared Spec relation builders", + satisfies: ref("spec:model.relations"), +}); + +void specRelationsAnchor; diff --git a/src/model/sections.ts b/src/model/sections.ts index ad97aeb..cac8cbb 100644 --- a/src/model/sections.ts +++ b/src/model/sections.ts @@ -1,3 +1,6 @@ +import { codeAnchor } from "./code-anchor.js"; +import { codeAnchorId, ref } from "../ids.js"; + export const SPEC_SECTION_NAMES = [ "intent", "behavior", @@ -119,3 +122,11 @@ export interface SpecSections { readonly verification?: VerificationSection; readonly ui?: UiSection; } + +const specSectionsAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.spec-sections"), + label: "typed Spec section shapes", + satisfies: ref("spec:model.spec-sections"), +}); + +void specSectionsAnchor; diff --git a/src/model/spec.ts b/src/model/spec.ts index ff588f4..76a0789 100644 --- a/src/model/spec.ts +++ b/src/model/spec.ts @@ -1,3 +1,6 @@ +import { codeAnchor } from "./code-anchor.js"; + +import { codeAnchorId, ref } from "../ids.js"; import type { SpecId } from "../ids.js"; import type { SpecAltitude, SpecKind, SpecReadiness } from "./descriptors.js"; import type { SpecRelation } from "./relations.js"; @@ -14,6 +17,14 @@ export interface Spec extends SpecSections { readonly relations?: readonly SpecRelation[]; } +const specPrimitiveAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.spec-primitive"), + label: "Spec envelope and enrich-in-place shape", + satisfies: ref("spec:model.core-model"), +}); + +void specPrimitiveAnchor; + export function spec(definition: Spec): Spec { return { ...definition, diff --git a/src/projections/design-review-context.ts b/src/projections/design-review-context.ts new file mode 100644 index 0000000..fec6484 --- /dev/null +++ b/src/projections/design-review-context.ts @@ -0,0 +1,174 @@ +import { SPEC_READINESS } from "../model/descriptors.js"; +import type { RelationEnd, SpecContext, VerifierBinding } from "../reader/reader.js"; +import type { Finding } from "../validate/contracts.js"; +import { escapeRenderedField } from "./owned-prose.js"; +import { + pageHref, + pagePathOf, + renderTableInlineCode, + sourceHref, + tableCell, +} from "./design-review-markdown.js"; + +export function renderReadiness(context: SpecContext): readonly string[] { + const derived = context.derivedReadiness; + const lines = [ + `**Readiness:** stated \`${context.statedReadiness}\` · structural floor reached: ${ + derived === undefined ? "none (the `idea` floor is unmet)" : `\`${derived}\`` + }`, + ]; + + const statedRank = SPEC_READINESS.indexOf(context.statedReadiness); + const derivedRank = derived === undefined ? -1 : SPEC_READINESS.indexOf(derived); + + if (derivedRank < statedRank) { + const firstUnmet = context.floorFailures[0]; + const clause = + firstUnmet === undefined + ? "" + : ` First unmet clause: \`${firstUnmet.clauseId}\` — ${escapeRenderedField(firstUnmet.description)}`; + lines.push( + "", + `> **Readiness divergence.** This spec states \`${context.statedReadiness}\` but the structural floor reached is ${ + derived === undefined ? "below `idea`" : `\`${derived}\`` + }.${clause}`, + ); + } + + return lines; +} + +function describeVerifier(verifier: VerifierBinding): string { + if (verifier.via === "test-anchor") { + return verifier.enabled + ? "the enabled verifying binding (a resolving test anchor)" + : "**not enabled** (an off-contract `verifies` edge — it confers no verifier binding)"; + } + + if (verifier.enabled) { + return "**enabled** (a resolving test anchor binds this example)"; + } + + return verifier.claim === "declared" + ? "**not enabled** (no test anchor binds this example — it confers no verifier binding)" + : "**not enabled** (an off-contract `verifies` edge — it confers no verifier binding)"; +} + +export function renderBindings(context: SpecContext, page: string): readonly string[] { + const present = (fact: "implemented" | "has-verifier"): string => + context.deliveryFacts.includes(fact) ? "present" : "none"; + + const lines = [ + "## Bindings", + "", + `- Implementation binding: **${present("implemented")}**`, + `- Verifier binding: **${present("has-verifier")}**`, + `- Expected-outcome oracle: **${context.oracle === undefined ? "none" : "present"}**`, + "- Runtime observation: **not tracked**", + ]; + + if (context.implementations.length > 0) { + lines.push("", "### Implementations", ""); + + for (const binding of context.implementations) { + const label = binding.label === undefined ? "" : ` — ${escapeRenderedField(binding.label)}`; + const location = + binding.file === undefined + ? "" + : ` ([${escapeRenderedField(binding.file)}${binding.line === undefined ? "" : `:${String(binding.line)}`}](${sourceHref(page, binding.file)}))`; + lines.push(`- \`${binding.codeId}\`${label}${location} \`[${binding.claim}]\``); + } + } + + if (context.verifiers.length > 0) { + lines.push("", "### Verifiers", ""); + + for (const verifier of context.verifiers) { + const label = verifier.label === undefined ? "" : ` — ${escapeRenderedField(verifier.label)}`; + const location = + verifier.file === undefined + ? "" + : ` ([${escapeRenderedField(verifier.file)}${verifier.line === undefined ? "" : `:${String(verifier.line)}`}](${sourceHref(page, verifier.file)}))`; + lines.push( + `- \`${verifier.verifierId}\`${label}${location} — ${describeVerifier(verifier)} \`[${verifier.claim}]\``, + ); + } + } + + if (context.oracle !== undefined) { + const oracle = context.oracle; + const label = oracle.label === undefined ? "" : ` — ${escapeRenderedField(oracle.label)}`; + const location = + oracle.file === undefined + ? "" + : ` ([${escapeRenderedField(oracle.file)}${oracle.line === undefined ? "" : `:${String(oracle.line)}`}](${sourceHref(page, oracle.file)}))`; + lines.push("", "### Expected-outcome oracle", ""); + lines.push( + `- \`${oracle.anchorId}\`${label}${location} — the authored expected-outcome semantics for this spec's example space; the graph records that it exists, never what it says \`[${oracle.claim}]\``, + ); + } + + return lines; +} + +function linkTo(page: string, end: RelationEnd): string { + const display = end.otherTitle === undefined ? "" : ` — ${escapeRenderedField(end.otherTitle)}`; + + if (end.resolved && (end.otherNodeType === "Primitive" || end.otherNodeType === "Pack")) { + return `[\`${end.otherId}\`](${pageHref(page, pagePathOf(end.otherId))})${display}`; + } + + return end.resolved + ? `\`${end.otherId}\`${display}` + : `\`${end.otherId}\` — **unresolved** (see findings)`; +} + +export function renderRelationsAndImpact(context: SpecContext, page: string): readonly string[] { + const outgoing = context.relationsOut; + const incoming = context.relationsIn.filter((end) => end.type !== "verifies"); + const lines: string[] = [ + "## Relations & impact (one hop)", + "", + "Every line is a one-hop neighbor over the curated graph: changing this spec touches this list plus the bindings above. Deeper reach is a script over the reader; symbol-level reach is the aspirational impact graph.", + "", + ]; + + if (context.packs.length > 0) { + const packLinks = context.packs + .map((packId) => `[\`${packId}\`](${pageHref(page, pagePathOf(packId))})`) + .join(" · "); + lines.push(`- Belongs to: ${packLinks} \`[declared]\``); + } + + for (const end of outgoing) { + lines.push(`- ${end.type} → ${linkTo(page, end)} \`[${end.claim}]\``); + } + + for (const end of incoming) { + lines.push(`- ${linkTo(page, end)} — ${end.type} → this spec \`[${end.claim}]\``); + } + + return lines.length === 4 ? [] : lines; +} + +export function renderFindings(findings: readonly Finding[]): readonly string[] { + if (findings.length === 0) { + return ["## Findings", "", "None — conformance + honesty clean for this page's subject."]; + } + + const lines = ["## Findings", "", "| Severity | Check | Message | Where |", "|---|---|---|---|"]; + + for (const finding of findings) { + const where = + finding.file === undefined + ? "—" + : renderTableInlineCode( + `${finding.file}${finding.line === undefined ? "" : `:${String(finding.line)}`}`, + ); + lines.push( + `| ${tableCell(finding.severity)} | ${renderTableInlineCode(finding.validatorId)} | ${tableCell(finding.message)} | ${where} |`, + ); + } + + return lines; +} diff --git a/src/projections/design-review-markdown.ts b/src/projections/design-review-markdown.ts new file mode 100644 index 0000000..d1a0248 --- /dev/null +++ b/src/projections/design-review-markdown.ts @@ -0,0 +1,109 @@ +import { escapeRenderedField } from "./owned-prose.js"; + +/** `spec:orders.create-order` → `spec/orders.create-order.md` — bijective by the id grammar (one `:`). */ +export function pagePathOf(id: string): string { + const colonIndex = id.indexOf(":"); + + return `${id.slice(0, colonIndex)}/${id.slice(colonIndex + 1)}.md`; +} + +function directoryOf(pagePath: string): readonly string[] { + return pagePath.split("/").slice(0, -1); +} + +/** Relative link between two view pages. */ +export function pageHref(fromPage: string, toPage: string): string { + const fromDirectory = directoryOf(fromPage); + const toParts = toPage.split("/"); + let shared = 0; + + while (shared < fromDirectory.length && fromDirectory[shared] === toParts[shared]) { + shared += 1; + } + + return `${"../".repeat(fromDirectory.length - shared)}${toParts.slice(shared).join("/")}`; +} + +/** Relative link from a view page to a repo file recorded in the graph (root-relative, JS-C3). */ +export function sourceHref(fromPage: string, file: string): string { + // Up out of the page's directories, then out of `design-review/` and `generated/`. + return `${"../".repeat(directoryOf(fromPage).length + 2)}${file}`; +} + +/** One-line table cell: field escaping plus collapsed newlines keeps content inside the table. */ +export function tableCell(text: string): string { + return escapeRenderedField(text).replaceAll(/\s+/gu, " ").trim(); +} + +export function heading(title: string | undefined, id: string): string { + return `# ${escapeRenderedField(title ?? id)}`; +} + +export const PAGE_FOOTER = + "*Generated from the one graph by `sdp view` — read-only; regenerate to update.*"; + +export function asRecord(value: unknown): Record | undefined { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return value as Record; + } + + return undefined; +} + +export function asArray(value: unknown): readonly unknown[] | undefined { + return Array.isArray(value) ? (value as readonly unknown[]) : undefined; +} + +export function asText(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +export function textEntries(value: unknown): readonly string[] { + return (asArray(value) ?? []).flatMap((entry) => { + const text = asText(entry); + + return text === undefined ? [] : [text]; + }); +} + +export function renderInlineCode(value: string): string { + const runs = value.match(/`+/gu) ?? []; + const longest = runs.reduce((length, run) => Math.max(length, run.length), 0); + const delimiter = "`".repeat(longest + 1); + // CommonMark strips one boundary space pair, so pad to keep authored boundary spaces; an all-spaces value cannot round-trip — accepted display-only approximation. + const padding = + value.startsWith("`") || value.endsWith("`") || value.startsWith(" ") || value.endsWith(" ") + ? " " + : ""; + + return `${delimiter}${padding}${value}${padding}${delimiter}`; +} + +/** Inline code inside a GFM table row: a pipe must be \|-escaped even inside a code span, and whitespace collapses so a stray newline cannot split the row. */ +export function renderTableInlineCode(value: string): string { + return renderInlineCode(value.replaceAll("|", "\\|").replaceAll(/\s+/gu, " ").trim()); +} + +function renderDynamicValue(value: unknown): unknown { + const record = asRecord(value); + + if (record !== undefined) { + return renderDynamicRecord(record); + } + + const entries = asArray(value); + + if (entries !== undefined) { + return entries.map((entry) => renderDynamicValue(entry)); + } + + return value; +} + +export function renderDynamicRecord(content: Record): Record { + return Object.fromEntries( + Object.keys(content) + .sort() + .map((key) => [key, renderDynamicValue(content[key])]), + ); +} diff --git a/src/projections/design-review-pages.ts b/src/projections/design-review-pages.ts new file mode 100644 index 0000000..ecc4871 --- /dev/null +++ b/src/projections/design-review-pages.ts @@ -0,0 +1,152 @@ +import type { PackContext, Reader, SpecContext, SpecSummary } from "../reader/reader.js"; +import { escapeRenderedField, renderNarrative } from "./owned-prose.js"; +import type { DesignReviewPage } from "./design-review.js"; +import { + renderBindings, + renderFindings, + renderReadiness, + renderRelationsAndImpact, +} from "./design-review-context.js"; +import { + heading, + PAGE_FOOTER, + pageHref, + pagePathOf, + sourceHref, + tableCell, +} from "./design-review-markdown.js"; +import { renderSections } from "./design-review-sections.js"; + +export function renderSpecPage(context: SpecContext): DesignReviewPage { + const page = pagePathOf(context.id); + const kind = + context.kindDisplayLabel === undefined + ? `\`${context.specKind}\`` + : `${escapeRenderedField(context.kindDisplayLabel)} (\`${context.specKind}\`)`; + const lines = [ + heading(context.title, context.id), + "", + `\`${context.id}\` · ${kind} · altitude \`${context.altitude}\` · authored in [${escapeRenderedField(context.file)}](${sourceHref(page, context.file)}) \`[declared]\``, + "", + ...renderNarrative(context.narrative), + ...renderReadiness(context), + "", + ...renderBindings(context, page), + ...renderSections(context), + ]; + + const relations = renderRelationsAndImpact(context, page); + + if (relations.length > 0) { + lines.push("", ...relations); + } + + lines.push("", ...renderFindings(context.findings), "", "---", "", PAGE_FOOTER); + + return { path: page, content: `${lines.join("\n")}\n` }; +} + +export function renderPackPage( + context: PackContext, + specLabel: (id: string) => string, +): DesignReviewPage { + const page = pagePathOf(context.id); + const lines = [ + heading(context.title, context.id), + "", + `\`${context.id}\` · Pack (the grouping / review aggregate — states no truth of its own) · authored in [${escapeRenderedField(context.file)}](${sourceHref(page, context.file)}) \`[declared]\``, + ]; + + if (context.framing !== undefined) { + lines.push("", `> ${escapeRenderedField(context.framing)}`); + } + + lines.push( + "", + "## Members", + "", + "| Spec | Kind | Altitude | Stated | Floor reached | Implementation binding | Verifier binding |", + "|---|---|---|---|---|---|---|", + ); + + for (const member of context.members) { + if (!member.resolved) { + lines.push(`| \`${member.id}\` — **unresolved** (see findings) | — | — | — | — | — | — |`); + continue; + } + + const link = `[\`${member.id}\`](${pageHref(page, pagePathOf(member.id))})`; + const present = (fact: "implemented" | "has-verifier"): string => + member.deliveryFacts.includes(fact) ? "present" : "none"; + lines.push( + `| ${link} ${tableCell(member.title ?? "")} | ${member.specKind ?? "—"} | ${member.altitude ?? "—"} | ${member.statedReadiness ?? "—"} | ${member.derivedReadiness ?? "none"} | ${present("implemented")} | ${present("has-verifier")} |`, + ); + } + + if (context.modelRefs.length > 0) { + const refs = context.modelRefs.map((ref) => specLabel(ref)).join(" · "); + lines.push("", `**Vocabulary (\`modelRefs\`):** ${refs}`); + } + + const gaps = context.verifierGaps; + + if (gaps.length > 0) { + lines.push( + "", + "## Verifier coverage gaps", + "", + "Members with no verifier binding — a surfaced absence, informative, never a gate. `ready` members are the priority slice (designed, stated done, unverified):", + "", + ); + + for (const gap of gaps) { + const stated = + gap.statedReadiness === undefined ? "" : ` (stated \`${gap.statedReadiness}\`)`; + lines.push(`- ${specLabel(gap.id)}${stated}${gap.priority ? " — **priority**" : ""}`); + } + } + + lines.push("", ...renderFindings(context.findings), "", "---", "", PAGE_FOOTER); + + return { path: page, content: `${lines.join("\n")}\n` }; +} + +export function renderIndexPage(reader: Reader, specs: readonly SpecSummary[]): DesignReviewPage { + const page = "index.md"; + const packs = reader.packs(); + const findings = reader.findings(); + const lines = [ + "# Design Review", + "", + `The one generated read-only view — a pure projection of the one graph (\`graph.json\`, schema \`${reader.graph.schemaVersion}\`): ${String(reader.graph.nodes.length)} nodes · ${String(reader.graph.edges.length)} edges.`, + "", + "## Specs", + "", + "| Spec | Kind | Altitude | Stated | Floor reached | Implementation binding | Verifier binding |", + "|---|---|---|---|---|---|---|", + ]; + + for (const spec of specs) { + const link = `[\`${spec.id}\`](${pageHref(page, pagePathOf(spec.id))})`; + const present = (fact: "implemented" | "has-verifier"): string => + spec.deliveryFacts.includes(fact) ? "present" : "none"; + lines.push( + `| ${link} ${tableCell(spec.title ?? "")} | ${spec.specKind} | ${spec.altitude} | ${spec.statedReadiness} | ${spec.derivedReadiness ?? "none"} | ${present("implemented")} | ${present("has-verifier")} |`, + ); + } + + if (packs.length > 0) { + lines.push("", "## Packs", ""); + + for (const pack of packs) { + const framing = pack.framing === undefined ? "" : ` — ${escapeRenderedField(pack.framing)}`; + lines.push( + `- [\`${pack.id}\`](${pageHref(page, pagePathOf(pack.id))}) ${escapeRenderedField(pack.title ?? "")}${framing}`, + ); + } + } + + lines.push("", ...renderFindings(findings), "", "---", "", PAGE_FOOTER); + + return { path: page, content: `${lines.join("\n")}\n` }; +} diff --git a/src/projections/design-review-section-content.ts b/src/projections/design-review-section-content.ts new file mode 100644 index 0000000..2f9a905 --- /dev/null +++ b/src/projections/design-review-section-content.ts @@ -0,0 +1,170 @@ +import { renderStepText } from "../notation/slots.js"; +import { escapeRenderedField, sectionDescription } from "./owned-prose.js"; +import { asArray, asRecord, asText, tableCell, textEntries } from "./design-review-markdown.js"; + +export function renderIntent(intent: Record): readonly string[] { + const description = sectionDescription(intent); + const lines: string[] = ["## Intent", "", ...description]; + + if (description.length > 0) { + lines.push(""); + } + + for (const field of ["actor", "problem", "outcome", "value"]) { + const text = asText(intent[field]); + + if (text !== undefined) { + lines.push(`- **${field}:** ${escapeRenderedField(text)}`); + } + } + + for (const field of ["risks", "assumptions"]) { + const entries = textEntries(intent[field]); + + if (entries.length > 0) { + lines.push(`- **${field}:**`); + lines.push(...entries.map((entry) => ` - ${escapeRenderedField(entry)}`)); + } + } + + const openQuestions = asArray(intent.openQuestions) ?? []; + + if (openQuestions.length > 0) { + lines.push("", "### Open questions", ""); + + for (const entry of openQuestions) { + const prose = asText(entry); + + if (prose !== undefined) { + lines.push(`- ${escapeRenderedField(prose)}`); + continue; + } + + const structured = asRecord(entry); + const question = asText(structured?.question) ?? "(malformed open-question entry)"; + const blocking = structured?.blocking === true ? " — **blocking**" : ""; + lines.push(`- ${escapeRenderedField(question)}${blocking}`); + } + } + + return lines; +} + +export function renderBehavior(behavior: Record): readonly string[] { + const description = sectionDescription(behavior); + const lines: string[] = ["## Behavior"]; + + if (description.length > 0) { + lines.push("", ...description); + } + + const rules = textEntries(behavior.rules); + + if (rules.length > 0) { + lines.push("", "### Rules", "", ...rules.map((rule) => `- ${escapeRenderedField(rule)}`)); + } + + const examples = asArray(behavior.examples) ?? []; + + if (examples.length > 0) { + lines.push("", "### Examples", ""); + + for (const entry of examples) { + const prose = asText(entry); + + if (prose !== undefined) { + lines.push(`- ${escapeRenderedField(prose)}`); + continue; + } + + const structured = asRecord(entry); + + if (structured === undefined) { + continue; + } + + lines.push("- Example:"); + + for (const phase of ["given", "when", "then"]) { + const steps = textEntries(structured[phase]); + + if (steps.length > 0) { + lines.push(` - **${phase}**`); + lines.push(...steps.map((step) => ` - ${escapeRenderedField(renderStepText(step))}`)); + } + } + } + } + + const flows = textEntries(behavior.flows); + + if (flows.length > 0) { + lines.push("", "### Flows", "", ...flows.map((flow) => `- ${escapeRenderedField(flow)}`)); + } + + const space = asRecord(behavior.exampleSpace); + + if (space !== undefined) { + const spaceLines: string[] = []; + + for (const phase of ["given", "when", "then"]) { + const steps = textEntries(space[phase]); + + if (steps.length > 0) { + spaceLines.push(` - **${phase}**`); + spaceLines.push(...steps.map((step) => ` - ${escapeRenderedField(step)}`)); + } + } + + if (spaceLines.length > 0) { + lines.push("", "### Example space", "", "- Step vocabulary:", ...spaceLines); + } + } + + return lines.length === 1 ? [] : lines; +} + +export function renderConstraints(entries: readonly unknown[]): readonly string[] { + const lines = [ + "## Constraints", + "", + "| Flavor | Statement | Target | Measurable by |", + "|---|---|---|---|", + ]; + + for (const entry of entries) { + const constraint = asRecord(entry) ?? {}; + const cell = (field: string): string => tableCell(asText(constraint[field]) ?? "—"); + lines.push( + `| ${cell("flavor")} | ${cell("statement")} | ${cell("target")} | ${cell("measurableBy")} |`, + ); + } + + return lines; +} + +export function renderModel(model: Record): readonly string[] { + const terms = asRecord(model.terms) ?? {}; + const names = Object.keys(terms).sort(); + const description = sectionDescription(model); + + if (names.length === 0 && description.length === 0) { + return []; + } + + const lines = ["## Domain vocabulary"]; + + if (description.length > 0) { + lines.push("", ...description); + } + + if (names.length > 0) { + lines.push("", "| Term | Definition |", "|---|---|"); + } + + for (const name of names) { + lines.push(`| ${tableCell(name)} | ${tableCell(asText(terms[name]) ?? "—")} |`); + } + + return lines; +} diff --git a/src/projections/design-review-sections.ts b/src/projections/design-review-sections.ts new file mode 100644 index 0000000..bb0bb37 --- /dev/null +++ b/src/projections/design-review-sections.ts @@ -0,0 +1,160 @@ +import type { SpecContext } from "../reader/reader.js"; +import { escapeRenderedField, sectionDescription } from "./owned-prose.js"; +import { + asArray, + asRecord, + asText, + renderInlineCode, + renderDynamicRecord, + textEntries, +} from "./design-review-markdown.js"; +import { + renderBehavior, + renderConstraints, + renderIntent, + renderModel, +} from "./design-review-section-content.js"; + +function renderDecision(decision: Record): readonly string[] { + const lines: string[] = ["## Decision"]; + const description = sectionDescription(decision); + + if (description.length > 0) { + lines.push("", ...description); + } + + const context = asText(decision.context); + + if (context !== undefined) { + lines.push("", `**Context.** ${escapeRenderedField(context)}`); + } + + const chosen = asText(decision.decision); + + if (chosen !== undefined) { + lines.push("", `**Decision.** ${escapeRenderedField(chosen)}`); + } + + for (const field of ["rationale", "alternatives", "consequences"]) { + const entries = textEntries(decision[field]); + + if (entries.length > 0) { + lines.push( + "", + `**${field[0]?.toUpperCase() ?? ""}${field.slice(1)}.**`, + "", + ...entries.map((entry) => `- ${escapeRenderedField(entry)}`), + ); + } + } + + return lines.length === 1 ? [] : lines; +} + +function renderVerification(verification: Record): readonly string[] { + const lines: string[] = ["## Verification intent"]; + const description = sectionDescription(verification); + + if (description.length > 0) { + lines.push("", ...description); + } + + const mode = asText(verification.mode); + + if (mode !== undefined) { + lines.push("", `- **mode:** ${renderInlineCode(mode)}`); + } + + const criteria = textEntries(verification.criteria); + + if (criteria.length > 0) { + lines.push( + "", + "### Criteria", + "", + ...criteria.map((criterion) => `- ${escapeRenderedField(criterion)}`), + ); + } + + return lines.length === 1 ? [] : lines; +} + +/** The open bags (`design` / `ui`, L9): dynamic keys canonicalized, rendered as data. */ +function renderOpenBag(name: string, content: Record): readonly string[] { + const description = sectionDescription(content); + const data = renderDynamicRecord( + Object.fromEntries(Object.entries(content).filter(([key]) => key !== "description")), + ); + + if (Object.keys(data).length === 0 && description.length === 0) { + return []; + } + + const lines = [`## ${name[0]?.toUpperCase() ?? ""}${name.slice(1)}`]; + + if (description.length > 0) { + lines.push("", ...description); + } + + if (Object.keys(data).length > 0) { + lines.push("", "```json", ...JSON.stringify(data, null, 2).split("\n"), "```"); + } + + return lines; +} + +export function renderSections(context: SpecContext): readonly string[] { + const sections = (context.sections ?? {}) as Record; + const lines: string[] = []; + const append = (rendered: readonly string[]): void => { + if (rendered.length > 0) { + lines.push("", ...rendered); + } + }; + + const intent = asRecord(sections.intent); + + if (intent !== undefined) { + append(renderIntent(intent)); + } + + const behavior = asRecord(sections.behavior); + + if (behavior !== undefined) { + append(renderBehavior(behavior)); + } + + const constraints = asArray(sections.constraints); + + if (constraints !== undefined && constraints.length > 0) { + append(renderConstraints(constraints)); + } + + const model = asRecord(sections.model); + + if (model !== undefined) { + append(renderModel(model)); + } + + const decision = asRecord(sections.decision); + + if (decision !== undefined) { + append(renderDecision(decision)); + } + + const verification = asRecord(sections.verification); + + if (verification !== undefined) { + append(renderVerification(verification)); + } + + for (const name of ["design", "ui"]) { + const bag = asRecord(sections[name]); + + if (bag !== undefined) { + append(renderOpenBag(name, bag)); + } + } + + return lines; +} diff --git a/src/projections/design-review.ts b/src/projections/design-review.ts index 20b23ff..84a7a5a 100644 --- a/src/projections/design-review.ts +++ b/src/projections/design-review.ts @@ -1,15 +1,9 @@ -import { SPEC_READINESS } from "../model/descriptors.js"; -import { renderStepText } from "../notation/slots.js"; -import type { Finding } from "../validate/contracts.js"; -import { renderNarrative, sectionDescription } from "./owned-prose.js"; -import type { - PackContext, - Reader, - RelationEnd, - SpecContext, - SpecSummary, - VerifierBinding, -} from "../reader/reader.js"; +import { codeAnchor } from "../model/code-anchor.js"; +import { codeAnchorId, ref } from "../ids.js"; +import type { Reader } from "../reader/reader.js"; +import { escapeRenderedField } from "./owned-prose.js"; +import { pageHref, pagePathOf } from "./design-review-markdown.js"; +import { renderIndexPage, renderPackPage, renderSpecPage } from "./design-review-pages.js"; /** * The Design Review — the one MVP read-only human view (`06` §5): a pure projection of the one @@ -30,699 +24,21 @@ export interface DesignReviewPage { readonly content: string; } -/* ----- deterministic Markdown plumbing ----- */ +const projectionModelAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.projections-model"), + label: "pure generated projection page contract", + satisfies: ref("spec:consumers.projections-model"), +}); -/** `spec:orders.create-order` → `spec/orders.create-order.md` — bijective by the id grammar (one `:`). */ -function pagePathOf(id: string): string { - const colonIndex = id.indexOf(":"); +void projectionModelAnchor; - return `${id.slice(0, colonIndex)}/${id.slice(colonIndex + 1)}.md`; -} - -function directoryOf(pagePath: string): readonly string[] { - return pagePath.split("/").slice(0, -1); -} - -/** Relative link between two view pages. */ -function pageHref(fromPage: string, toPage: string): string { - const fromDirectory = directoryOf(fromPage); - const toParts = toPage.split("/"); - let shared = 0; - - while (shared < fromDirectory.length && fromDirectory[shared] === toParts[shared]) { - shared += 1; - } - - return `${"../".repeat(fromDirectory.length - shared)}${toParts.slice(shared).join("/")}`; -} - -/** Relative link from a view page to a repo file recorded in the graph (root-relative, JS-C3). */ -function sourceHref(fromPage: string, file: string): string { - // Up out of the page's directories, then out of `design-review/` and `generated/`. - return `${"../".repeat(directoryOf(fromPage).length + 2)}${file}`; -} - -/** One-line table cell: pipes escaped, newlines collapsed — content never breaks the table. */ -function tableCell(text: string): string { - return text.replaceAll("|", "\\|").replaceAll(/\s+/gu, " ").trim(); -} - -function heading(title: string | undefined, id: string): string { - return `# ${title ?? id}`; -} - -const PAGE_FOOTER = - "*Generated from the one graph by `sdp view` — read-only; regenerate to update.*"; - -/* ----- defensive value access (sections are reified value data, never typed instances) ----- */ - -function asRecord(value: unknown): Record | undefined { - if (typeof value === "object" && value !== null && !Array.isArray(value)) { - return value as Record; - } - - return undefined; -} - -function asArray(value: unknown): readonly unknown[] | undefined { - return Array.isArray(value) ? (value as readonly unknown[]) : undefined; -} - -function asText(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value : undefined; -} - -function textEntries(value: unknown): readonly string[] { - return (asArray(value) ?? []).flatMap((entry) => { - const text = asText(entry); - - return text === undefined ? [] : [text]; - }); -} - -/* ----- the readiness header (`05` §3, `07` §6 ③) ----- */ - -function renderReadiness(context: SpecContext): readonly string[] { - const derived = context.derivedReadiness; - const lines = [ - `**Readiness:** stated \`${context.statedReadiness}\` · structural floor reached: ${ - derived === undefined ? "none (the `idea` floor is unmet)" : `\`${derived}\`` - }`, - ]; - - const statedRank = SPEC_READINESS.indexOf(context.statedReadiness); - const derivedRank = derived === undefined ? -1 : SPEC_READINESS.indexOf(derived); - - // The banner fires only in the dishonest direction: derived at-or-above stated is ordinary - // information (the floor is never a quota and never nags upward). - if (derivedRank < statedRank) { - const firstUnmet = context.floorFailures[0]; - const clause = - firstUnmet === undefined - ? "" - : ` First unmet clause: \`${firstUnmet.clauseId}\` — ${firstUnmet.description}`; - lines.push( - "", - `> **Readiness divergence.** This spec states \`${context.statedReadiness}\` but the structural floor reached is ${ - derived === undefined ? "below `idea`" : `\`${derived}\`` - }.${clause}`, - ); - } - - return lines; -} - -/* ----- bindings in binding language (`07` §6 ④) ----- */ - -function describeVerifier(verifier: VerifierBinding): string { - if (verifier.via === "test-anchor") { - return verifier.enabled - ? "the enabled verifying binding (a resolving test anchor)" - : "**not enabled** (an off-contract `verifies` edge — it confers no verifier binding)"; - } - - if (verifier.enabled) { - return "**enabled** (a resolving test anchor binds this example)"; - } - - // Name the actual cause: an off-contract claim confers nothing even when a test anchor binds - // the example, so blaming a missing anchor would send the reader hunting for one that exists. - return verifier.claim === "declared" - ? "**not enabled** (no test anchor binds this example — it confers no verifier binding)" - : "**not enabled** (an off-contract `verifies` edge — it confers no verifier binding)"; -} - -function renderBindings(context: SpecContext, page: string): readonly string[] { - const present = (fact: "implemented" | "has-verifier"): string => - context.deliveryFacts.includes(fact) ? "present" : "none"; - - const lines = [ - "## Bindings", - "", - `- Implementation binding: **${present("implemented")}**`, - `- Verifier binding: **${present("has-verifier")}**`, - // The oracle binding renders from the decode, never a delivery fact — the graph records that - // an expected-outcome oracle exists, nothing more (settlement 8; rendered surfaces say - // "expected outcome" per the glossary). - `- Expected-outcome oracle: **${context.oracle === undefined ? "none" : "present"}**`, - "- Runtime observation: **not tracked**", - ]; - - if (context.implementations.length > 0) { - lines.push("", "### Implementations", ""); - - for (const binding of context.implementations) { - const label = binding.label === undefined ? "" : ` — ${binding.label}`; - const location = - binding.file === undefined - ? "" - : ` ([${binding.file}${binding.line === undefined ? "" : `:${String(binding.line)}`}](${sourceHref(page, binding.file)}))`; - lines.push(`- \`${binding.codeId}\`${label}${location} \`[${binding.claim}]\``); - } - } - - if (context.verifiers.length > 0) { - lines.push("", "### Verifiers", ""); - - for (const verifier of context.verifiers) { - const label = verifier.label === undefined ? "" : ` — ${verifier.label}`; - const location = - verifier.file === undefined - ? "" - : ` ([${verifier.file}${verifier.line === undefined ? "" : `:${String(verifier.line)}`}](${sourceHref(page, verifier.file)}))`; - lines.push( - `- \`${verifier.verifierId}\`${label}${location} — ${describeVerifier(verifier)} \`[${verifier.claim}]\``, - ); - } - } - - if (context.oracle !== undefined) { - const oracle = context.oracle; - const label = oracle.label === undefined ? "" : ` — ${oracle.label}`; - const location = - oracle.file === undefined - ? "" - : ` ([${oracle.file}${oracle.line === undefined ? "" : `:${String(oracle.line)}`}](${sourceHref(page, oracle.file)}))`; - lines.push("", "### Expected-outcome oracle", ""); - lines.push( - `- \`${oracle.anchorId}\`${label}${location} — the authored expected-outcome semantics for this spec's example space; the graph records that it exists, never what it says \`[${oracle.claim}]\``, - ); - } - - return lines; -} - -/* ----- section content ----- */ - -function renderIntent(intent: Record): readonly string[] { - const description = sectionDescription(intent); - const lines: string[] = ["## Intent", "", ...description]; - - if (description.length > 0) { - lines.push(""); - } - - for (const field of ["actor", "problem", "outcome", "value"]) { - const text = asText(intent[field]); - - if (text !== undefined) { - lines.push(`- **${field}:** ${text}`); - } - } - - for (const field of ["risks", "assumptions"]) { - const entries = textEntries(intent[field]); - - if (entries.length > 0) { - lines.push(`- **${field}:**`); - lines.push(...entries.map((entry) => ` - ${entry}`)); - } - } - - const openQuestions = asArray(intent.openQuestions) ?? []; - - if (openQuestions.length > 0) { - lines.push("", "### Open questions", ""); - - for (const entry of openQuestions) { - const prose = asText(entry); - - if (prose !== undefined) { - lines.push(`- ${prose}`); - continue; - } - - const structured = asRecord(entry); - const question = asText(structured?.question) ?? "(malformed open-question entry)"; - const blocking = structured?.blocking === true ? " — **blocking**" : ""; - lines.push(`- ${question}${blocking}`); - } - } - - return lines; -} - -function renderBehavior(behavior: Record): readonly string[] { - const description = sectionDescription(behavior); - const lines: string[] = ["## Behavior"]; - - if (description.length > 0) { - lines.push("", ...description); - } - - const rules = textEntries(behavior.rules); - - if (rules.length > 0) { - lines.push("", "### Rules", "", ...rules.map((rule) => `- ${rule}`)); - } - - const examples = asArray(behavior.examples) ?? []; - - if (examples.length > 0) { - lines.push("", "### Examples", ""); - - for (const entry of examples) { - const prose = asText(entry); - - if (prose !== undefined) { - lines.push(`- ${prose}`); - continue; - } - - const structured = asRecord(entry); - - if (structured === undefined) { - continue; - } - - lines.push("- Example:"); - - for (const phase of ["given", "when", "then"]) { - const steps = textEntries(structured[phase]); - - if (steps.length > 0) { - lines.push(` - **${phase}**`); - lines.push(...steps.map((step) => ` - ${renderStepText(step)}`)); - } - } - } - } - - const flows = textEntries(behavior.flows); - - if (flows.length > 0) { - lines.push("", "### Flows", "", ...flows.map((flow) => `- ${flow}`)); - } - - // The example space renders verbatim — the typed slot declarations ARE the reviewable content - // (explicit binding is authored truth; natural reading belongs to the interactive harness). - const space = asRecord(behavior.exampleSpace); - - if (space !== undefined) { - const spaceLines: string[] = []; - - for (const phase of ["given", "when", "then"]) { - const steps = textEntries(space[phase]); - - if (steps.length > 0) { - spaceLines.push(` - **${phase}**`); - spaceLines.push(...steps.map((step) => ` - ${step}`)); - } - } - - if (spaceLines.length > 0) { - lines.push("", "### Example space", "", "- Step vocabulary:", ...spaceLines); - } - } - - return lines.length === 1 ? [] : lines; -} - -function renderConstraints(entries: readonly unknown[]): readonly string[] { - const lines = [ - "## Constraints", - "", - "| Flavor | Statement | Target | Measurable by |", - "|---|---|---|---|", - ]; - - for (const entry of entries) { - const constraint = asRecord(entry) ?? {}; - const cell = (field: string): string => tableCell(asText(constraint[field]) ?? "—"); - lines.push( - `| ${cell("flavor")} | ${cell("statement")} | ${cell("target")} | ${cell("measurableBy")} |`, - ); - } - - return lines; -} - -function renderModel(model: Record): readonly string[] { - const terms = asRecord(model.terms) ?? {}; - const names = Object.keys(terms); - const description = sectionDescription(model); - - if (names.length === 0 && description.length === 0) { - return []; - } - - const lines = ["## Domain vocabulary"]; - - if (description.length > 0) { - lines.push("", ...description); - } - - if (names.length > 0) { - lines.push("", "| Term | Definition |", "|---|---|"); - } - - for (const name of names) { - lines.push(`| ${tableCell(name)} | ${tableCell(asText(terms[name]) ?? "—")} |`); - } - - return lines; -} - -function renderDecision(decision: Record): readonly string[] { - const lines: string[] = ["## Decision"]; - const description = sectionDescription(decision); +const designReviewAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.design-review"), + label: "renders the contextual Design Review projection", + satisfies: ref("spec:consumers.design-review"), +}); - if (description.length > 0) { - lines.push("", ...description); - } - - const context = asText(decision.context); - - if (context !== undefined) { - lines.push("", `**Context.** ${context}`); - } - - const chosen = asText(decision.decision); - - if (chosen !== undefined) { - lines.push("", `**Decision.** ${chosen}`); - } - - for (const field of ["rationale", "alternatives", "consequences"]) { - const entries = textEntries(decision[field]); - - if (entries.length > 0) { - lines.push( - "", - `**${field[0]?.toUpperCase() ?? ""}${field.slice(1)}.**`, - "", - ...entries.map((entry) => `- ${entry}`), - ); - } - } - - return lines.length === 1 ? [] : lines; -} - -function renderVerification(verification: Record): readonly string[] { - const lines: string[] = ["## Verification intent"]; - const description = sectionDescription(verification); - - if (description.length > 0) { - lines.push("", ...description); - } - - const mode = asText(verification.mode); - - if (mode !== undefined) { - lines.push("", `- **mode:** \`${mode}\``); - } - - const criteria = textEntries(verification.criteria); - - if (criteria.length > 0) { - lines.push("", "### Criteria", "", ...criteria.map((criterion) => `- ${criterion}`)); - } - - return lines.length === 1 ? [] : lines; -} - -/** The open bags (`design` / `ui`, L9): authored order preserved, rendered as data. */ -function renderOpenBag(name: string, content: Record): readonly string[] { - const description = sectionDescription(content); - const data = Object.fromEntries(Object.entries(content).filter(([key]) => key !== "description")); - - if (Object.keys(data).length === 0 && description.length === 0) { - return []; - } - - const lines = [`## ${name[0]?.toUpperCase() ?? ""}${name.slice(1)}`]; - - if (description.length > 0) { - lines.push("", ...description); - } - - if (Object.keys(data).length > 0) { - lines.push("", "```json", ...JSON.stringify(data, null, 2).split("\n"), "```"); - } - - return lines; -} - -function renderSections(context: SpecContext): readonly string[] { - const sections = (context.sections ?? {}) as Record; - const lines: string[] = []; - const append = (rendered: readonly string[]): void => { - if (rendered.length > 0) { - lines.push("", ...rendered); - } - }; - - const intent = asRecord(sections.intent); - - if (intent !== undefined) { - append(renderIntent(intent)); - } - - const behavior = asRecord(sections.behavior); - - if (behavior !== undefined) { - append(renderBehavior(behavior)); - } - - const constraints = asArray(sections.constraints); - - if (constraints !== undefined && constraints.length > 0) { - append(renderConstraints(constraints)); - } - - const model = asRecord(sections.model); - - if (model !== undefined) { - append(renderModel(model)); - } - - const decision = asRecord(sections.decision); - - if (decision !== undefined) { - append(renderDecision(decision)); - } - - const verification = asRecord(sections.verification); - - if (verification !== undefined) { - append(renderVerification(verification)); - } - - for (const name of ["design", "ui"]) { - const bag = asRecord(sections[name]); - - if (bag !== undefined) { - append(renderOpenBag(name, bag)); - } - } - - return lines; -} - -/* ----- relations, impact, findings ----- */ - -function linkTo(page: string, end: RelationEnd): string { - const display = end.otherTitle === undefined ? "" : ` — ${end.otherTitle}`; - - if (end.resolved && (end.otherNodeType === "Primitive" || end.otherNodeType === "Pack")) { - return `[\`${end.otherId}\`](${pageHref(page, pagePathOf(end.otherId))})${display}`; - } - - return end.resolved - ? `\`${end.otherId}\`${display}` - : `\`${end.otherId}\` — **unresolved** (see findings)`; -} - -/** - * One section, two readings (JS-E1/JS-G1): the relation list *is* the per-spec impact list — - * every line is a one-hop neighbor, so a change to this spec touches exactly this list plus the - * bindings above. Stated once, never as a second list to drift. - */ -function renderRelationsAndImpact(context: SpecContext, page: string): readonly string[] { - // Incoming `verifies` edges render under Bindings (the decoded verifier join), never twice; - // outgoing ones stay here — a verifier's own page must show what it covers (JS-G2). - const outgoing = context.relationsOut; - const incoming = context.relationsIn.filter((end) => end.type !== "verifies"); - const lines: string[] = [ - "## Relations & impact (one hop)", - "", - "Every line is a one-hop neighbor over the curated graph: changing this spec touches this list plus the bindings above. Deeper reach is a script over the reader; symbol-level reach is the aspirational impact graph.", - "", - ]; - - if (context.packs.length > 0) { - const packLinks = context.packs - .map((packId) => `[\`${packId}\`](${pageHref(page, pagePathOf(packId))})`) - .join(" · "); - lines.push(`- Belongs to: ${packLinks} \`[declared]\``); - } - - for (const end of outgoing) { - lines.push(`- ${end.type} → ${linkTo(page, end)} \`[${end.claim}]\``); - } - - for (const end of incoming) { - lines.push(`- ${linkTo(page, end)} — ${end.type} → this spec \`[${end.claim}]\``); - } - - return lines.length === 4 ? [] : lines; -} - -function renderFindings(findings: readonly Finding[]): readonly string[] { - if (findings.length === 0) { - return ["## Findings", "", "None — conformance + honesty clean for this page's subject."]; - } - - const lines = ["## Findings", "", "| Severity | Check | Message | Where |", "|---|---|---|---|"]; - - for (const finding of findings) { - // Location from the structured `file`/`line` fields — a source location *recorded in the - // graph* (R2); `Primitive` nodes are line-free by design, so the line renders only when known. - const where = - finding.file === undefined - ? "—" - : `\`${finding.file}${finding.line === undefined ? "" : `:${String(finding.line)}`}\``; - lines.push( - `| ${finding.severity} | \`${finding.validatorId}\` | ${tableCell(finding.message)} | ${where} |`, - ); - } - - return lines; -} - -/* ----- pages ----- */ - -function renderSpecPage(context: SpecContext): DesignReviewPage { - const page = pagePathOf(context.id); - const kind = - context.kindDisplayLabel === undefined - ? `\`${context.specKind}\`` - : `${context.kindDisplayLabel} (\`${context.specKind}\`)`; - const lines = [ - heading(context.title, context.id), - "", - `\`${context.id}\` · ${kind} · altitude \`${context.altitude}\` · authored in [${context.file}](${sourceHref(page, context.file)}) \`[declared]\``, - "", - ...renderNarrative(context.narrative), - ...renderReadiness(context), - "", - ...renderBindings(context, page), - ...renderSections(context), - ]; - - const relations = renderRelationsAndImpact(context, page); - - if (relations.length > 0) { - lines.push("", ...relations); - } - - lines.push("", ...renderFindings(context.findings), "", "---", "", PAGE_FOOTER); - - return { path: page, content: `${lines.join("\n")}\n` }; -} - -function renderPackPage(context: PackContext, specLabel: (id: string) => string): DesignReviewPage { - const page = pagePathOf(context.id); - const lines = [ - heading(context.title, context.id), - "", - `\`${context.id}\` · Pack (the grouping / review aggregate — states no truth of its own) · authored in [${context.file}](${sourceHref(page, context.file)}) \`[declared]\``, - ]; - - if (context.framing !== undefined) { - lines.push("", `> ${context.framing}`); - } - - lines.push( - "", - "## Members", - "", - "| Spec | Kind | Altitude | Stated | Floor reached | Implementation binding | Verifier binding |", - "|---|---|---|---|---|---|---|", - ); - - for (const member of context.members) { - if (!member.resolved) { - lines.push(`| \`${member.id}\` — **unresolved** (see findings) | — | — | — | — | — | — |`); - continue; - } - - const link = `[\`${member.id}\`](${pageHref(page, pagePathOf(member.id))})`; - const present = (fact: "implemented" | "has-verifier"): string => - member.deliveryFacts.includes(fact) ? "present" : "none"; - lines.push( - `| ${link} ${tableCell(member.title ?? "")} | ${member.specKind ?? "—"} | ${member.altitude ?? "—"} | ${member.statedReadiness ?? "—"} | ${member.derivedReadiness ?? "none"} | ${present("implemented")} | ${present("has-verifier")} |`, - ); - } - - if (context.modelRefs.length > 0) { - const refs = context.modelRefs.map((ref) => specLabel(ref)).join(" · "); - lines.push("", `**Vocabulary (\`modelRefs\`):** ${refs}`); - } - - const gaps = context.verifierGaps; - - if (gaps.length > 0) { - lines.push( - "", - "## Verifier coverage gaps", - "", - "Members with no verifier binding — a surfaced absence, informative, never a gate. `ready` members are the priority slice (designed, stated done, unverified):", - "", - ); - - for (const gap of gaps) { - const stated = - gap.statedReadiness === undefined ? "" : ` (stated \`${gap.statedReadiness}\`)`; - lines.push(`- ${specLabel(gap.id)}${stated}${gap.priority ? " — **priority**" : ""}`); - } - } - - lines.push("", ...renderFindings(context.findings), "", "---", "", PAGE_FOOTER); - - return { path: page, content: `${lines.join("\n")}\n` }; -} - -function renderIndexPage(reader: Reader, specs: readonly SpecSummary[]): DesignReviewPage { - const page = "index.md"; - const packs = reader.packs(); - const findings = reader.findings(); - const lines = [ - "# Design Review", - "", - `The one generated read-only view — a pure projection of the one graph (\`graph.json\`, schema \`${reader.graph.schemaVersion}\`): ${String(reader.graph.nodes.length)} nodes · ${String(reader.graph.edges.length)} edges.`, - "", - "## Specs", - "", - "| Spec | Kind | Altitude | Stated | Floor reached | Implementation binding | Verifier binding |", - "|---|---|---|---|---|---|---|", - ]; - - for (const spec of specs) { - const link = `[\`${spec.id}\`](${pageHref(page, pagePathOf(spec.id))})`; - const present = (fact: "implemented" | "has-verifier"): string => - spec.deliveryFacts.includes(fact) ? "present" : "none"; - lines.push( - `| ${link} ${tableCell(spec.title ?? "")} | ${spec.specKind} | ${spec.altitude} | ${spec.statedReadiness} | ${spec.derivedReadiness ?? "none"} | ${present("implemented")} | ${present("has-verifier")} |`, - ); - } - - if (packs.length > 0) { - lines.push("", "## Packs", ""); - - for (const pack of packs) { - const framing = pack.framing === undefined ? "" : ` — ${pack.framing}`; - lines.push( - `- [\`${pack.id}\`](${pageHref(page, pagePathOf(pack.id))}) ${pack.title ?? ""}${framing}`, - ); - } - } - - lines.push("", ...renderFindings(findings), "", "---", "", PAGE_FOOTER); - - return { path: page, content: `${lines.join("\n")}\n` }; -} +void designReviewAnchor; /** * Renders the full Design Review off the reader. Pure and fs-free: the caller owns writing the @@ -738,7 +54,7 @@ export function renderDesignReview(reader: Reader): readonly DesignReviewPage[] return known === undefined ? `\`${id}\`` - : `[\`${id}\`](${pageHref(page, pagePathOf(id))})${known.title === undefined ? "" : ` — ${known.title}`}`; + : `[\`${id}\`](${pageHref(page, pagePathOf(id))})${known.title === undefined ? "" : ` — ${escapeRenderedField(known.title)}`}`; }; }; diff --git a/src/projections/owned-prose.ts b/src/projections/owned-prose.ts index d08dae8..7c263b2 100644 --- a/src/projections/owned-prose.ts +++ b/src/projections/owned-prose.ts @@ -1,15 +1,16 @@ -function renderProse(text: string): readonly string[] { +export function escapeRenderedField(text: string): string { return text - .split("\n") - .map((line) => - line - .replaceAll("\\", "\\\\") - .replaceAll("`", "\\`") - .replaceAll("|", "\\|") - .replaceAll("#", "\\#") - .replaceAll("<", "<") - .replaceAll(">", ">"), - ); + .replaceAll("\\", "\\\\") + .replaceAll("&", "&") + .replaceAll("`", "\\`") + .replaceAll("|", "\\|") + .replaceAll("#", "\\#") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function renderProse(text: string): readonly string[] { + return text.split("\n").map((line) => escapeRenderedField(line)); } export function sectionDescription(content: Record): readonly string[] { diff --git a/src/reader/reader.ts b/src/reader/reader.ts index 66b68ff..e77090a 100644 --- a/src/reader/reader.ts +++ b/src/reader/reader.ts @@ -5,6 +5,8 @@ import { } from "../graph/delivery-facts.js"; import { isResolvingOracleModel } from "../graph/oracle-bindings.js"; import { authoredEdgeTypes } from "../graph/schema.js"; +import { codeAnchorId, ref } from "../ids.js"; +import { codeAnchor } from "../model/code-anchor.js"; import type { DeliveryFactName, GraphClaim, @@ -220,6 +222,14 @@ export interface BlastRadius { readonly coverageUnknown: readonly string[]; } +const readerImpactAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.reader-impact"), + label: "file-level reader blast-radius contract", + satisfies: ref("spec:consumers.reader"), +}); + +void readerImpactAnchor; + /* ----- the reader ----- */ /** @@ -337,6 +347,22 @@ function matchSections(sections: SpecSections | undefined, needle: string): read return matched.sort(compareCodeUnits); } +const agentSurfaceAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.agent-surface"), + label: "typed graph reader and agent entry adapters", + satisfies: ref("spec:consumers.agent-surface"), +}); + +void agentSurfaceAnchor; + +const readerAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.reader"), + label: "thin typed graph reader construction", + satisfies: ref("spec:consumers.reader"), +}); + +void readerAnchor; + export function createReader(graph: GraphSchema): Reader { const index = buildGraphIndex(graph); const recomputedFacts = computeDeliveryFacts(graph.nodes, graph.edges); diff --git a/src/validate/readiness-floor.ts b/src/validate/readiness-floor.ts index b572bed..f3f7103 100644 --- a/src/validate/readiness-floor.ts +++ b/src/validate/readiness-floor.ts @@ -1,5 +1,5 @@ -import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; - +import { codeAnchorId, ref } from "../ids.js"; +import { codeAnchor } from "../model/code-anchor.js"; import { SPEC_KINDS, SPEC_READINESS } from "../model/descriptors.js"; import type { SpecKind, SpecReadiness } from "../model/descriptors.js"; import type { SpecSectionName } from "../model/sections.js"; @@ -505,6 +505,14 @@ export const readinessFloorAnchor = codeAnchor({ satisfies: ref("spec:validation.readiness-floor"), }); +const verifierSemanticsAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.verifier-semantics"), + label: "readiness clauses over direct verification bindings", + satisfies: ref("spec:model.spec-sections"), +}); + +void verifierSemanticsAnchor; + export function evaluateReadinessFloor( node: PrimitiveNode, index: GraphIndex, diff --git a/src/validate/validators.ts b/src/validate/validators.ts index 1b16012..33e9568 100644 --- a/src/validate/validators.ts +++ b/src/validate/validators.ts @@ -1,6 +1,8 @@ import { computeDeliveryFacts, isResolvingTestAnchorVerify } from "../graph/delivery-facts.js"; import { isResolvingOracleModel, ownsExampleSpace } from "../graph/oracle-bindings.js"; import { deliveryFactNames, graphClaims, graphEdgeTypes, graphNodeTypes } from "../graph/schema.js"; +import { codeAnchorId, ref } from "../ids.js"; +import { codeAnchor } from "../model/code-anchor.js"; import type { DeliveryFactName, GraphClaim, @@ -43,6 +45,14 @@ export const graphValidatorIds = { gaps: "honesty/gaps", } as const; +const validationFamiliesAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.validation-families"), + label: "conformance and honesty validator registry", + satisfies: ref("spec:validation.two-check-families"), +}); + +void validationFamiliesAnchor; + /** The aggregate report id; it spans both check families, so it carries no single family. */ export const graphReportId = "graph"; diff --git a/test/anchor-trust.test.ts b/test/anchor-trust.test.ts new file mode 100644 index 0000000..375b2b8 --- /dev/null +++ b/test/anchor-trust.test.ts @@ -0,0 +1,122 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { extract } from "../src/index.js"; + +const repositoryRoot = fileURLToPath(new URL("..", import.meta.url)); +const temporaryRoots: string[] = []; + +function temporaryRoot(name: string): string { + const root = mkdtempSync(join(tmpdir(), `sdp-anchor-trust-${name}-`)); + temporaryRoots.push(root); + return root; +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +describe("Protocol builder trust", () => { + it("ignores consumer-local modules whose relative names resemble Protocol builders", () => { + const root = temporaryRoot("consumer-lookalike"); + mkdirSync(join(root, "src"), { recursive: true }); + mkdirSync(join(root, "specs"), { recursive: true }); + writeFileSync(join(root, "src", "code-anchor.ts"), "export const codeAnchor = () => ({});\n"); + writeFileSync( + join(root, "src", "ids.ts"), + "export const codeAnchorId = String; export const ref = String; export const spec = (value: unknown) => value; export const specId = String;\n", + ); + writeFileSync( + join(root, "src", "consumer.ts"), + `import { codeAnchor } from "./code-anchor.js"; +import { codeAnchorId, ref } from "./ids.js"; + +const binding = codeAnchor({ + id: codeAnchorId("impl:consumer.lookalike"), + satisfies: ref("spec:consumer.lookalike"), +}); +void binding; +`, + ); + writeFileSync( + join(root, "specs", "ids.ts"), + "export const spec = (value: unknown) => value; export const specId = String;\n", + ); + writeFileSync( + join(root, "specs", "lookalike.sdp.ts"), + `import { spec, specId } from "./ids.js"; + +export const lookalike = spec({ + id: specId("spec:consumer.lookalike"), + title: "Consumer lookalike", + kind: "behavior", + altitude: "story", + readiness: "idea", +}); +`, + ); + + const result = extract({ root }); + + expect(result.counts).toEqual({ specs: 0, packs: 0, anchors: 0 }); + expect(result.graph.nodes).toEqual([]); + expect(result.graph.edges).toEqual([]); + }); + + it("recognizes deep relative imports only when they resolve to the Protocol builder modules", () => { + const root = temporaryRoot("physical-identity"); + mkdirSync(join(root, "model"), { recursive: true }); + mkdirSync(join(root, "deep", "nested"), { recursive: true }); + symlinkSync(join(repositoryRoot, "src", "ids.ts"), join(root, "ids.ts")); + symlinkSync( + join(repositoryRoot, "src", "model", "code-anchor.ts"), + join(root, "model", "code-anchor.ts"), + ); + writeFileSync( + join(root, "deep", "nested", "binding.ts"), + `import { codeAnchor } from "../../model/code-anchor.js"; +import { codeAnchorId, ref } from "../../ids.js"; + +const binding = codeAnchor({ + id: codeAnchorId("impl:protocol.deep-binding"), + satisfies: ref("spec:protocol.deep-binding"), +}); +void binding; +`, + ); + + const result = extract({ root }); + + expect(result.report.findings).toEqual([]); + expect(result.counts.anchors).toBe(1); + expect(result.graph.nodes).toContainEqual( + expect.objectContaining({ id: "impl:protocol.deep-binding", nodeType: "CodeNode" }), + ); + }); + + it("continues to recognize package imports in consumer repositories", () => { + const root = temporaryRoot("package-import"); + writeFileSync( + join(root, "binding.ts"), + `import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; + +const binding = codeAnchor({ + id: codeAnchorId("impl:consumer.package-binding"), + satisfies: ref("spec:consumer.package-binding"), +}); +void binding; +`, + ); + + const result = extract({ root }); + + expect(result.report.findings).toEqual([]); + expect(result.counts.anchors).toBe(1); + }); +}); diff --git a/test/bootstrap.test.ts b/test/bootstrap.test.ts index 9456872..6624476 100644 --- a/test/bootstrap.test.ts +++ b/test/bootstrap.test.ts @@ -62,10 +62,10 @@ describe("bootstrap package surface", () => { expect(rootExport.types).toBe("./dist/index.d.ts"); expect(rootExport.import).toBe("./dist/index.js"); expect(packageJson.scripts["generate:self-hosting"]).toBe( - "node ./dist/cli/sdp.js view . --exclude explorations --exclude examples", + "node ./dist/cli/sdp.js view . --exclude explorations --exclude examples --exclude test/fixtures/import/parity", ); expect(packageJson.scripts["check:self-hosting"]).toBe( - "node ./dist/cli/sdp.js view . --exclude explorations --exclude examples --check-clean", + "node ./dist/cli/sdp.js view . --exclude explorations --exclude examples --exclude test/fixtures/import/parity --check-clean", ); expect(packageJson.scripts.preflight).toBe("node ./preflight.mjs"); expect(packageJson.scripts.check).toBe( diff --git a/test/cli-import-review-08.test.ts b/test/cli-import-review-08.test.ts new file mode 100644 index 0000000..a0d44fd --- /dev/null +++ b/test/cli-import-review-08.test.ts @@ -0,0 +1,531 @@ +import { + existsSync, + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { runSdpCli } from "../src/cli/sdp.js"; +import type { ImportResult } from "../src/index.js"; + +const caseProbeRoot = mkdtempSync(join(tmpdir(), "sdp-import-case-probe-")); +writeFileSync(join(caseProbeRoot, "ProbeCase.sdp.ts"), "probe\n"); +const caseInsensitive = existsSync(join(caseProbeRoot, "probecase.sdp.ts")); +rmSync(caseProbeRoot, { force: true, recursive: true }); + +function captureOutput() { + const stdout: string[] = []; + const stderr: string[] = []; + + return { + output: { + stdout: { write: (chunk: string) => stdout.push(chunk) }, + stderr: { write: (chunk: string) => stderr.push(chunk) }, + }, + stdout: () => stdout.join(""), + stderr: () => stderr.join(""), + }; +} + +function emitted(path: string): ImportResult { + return { + emitted: { path: path.replace(/\.sdp\.ts$/u, ".sdp.md"), content: "# Imported\n" }, + findings: [], + }; +} + +function refused(path: string): ImportResult { + return { + findings: [ + { + validatorId: "import/refusal", + family: "conformance", + severity: "error" as const, + message: "refused", + file: path, + line: 1, + }, + ], + }; +} + +describe("review-08 import regressions", () => { + it("skips tooling and dot directories without following directory symlinks", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-bounded-")); + const valid = join(root, "valid.sdp.ts"); + const ignored = ["node_modules", "dist", "generated", "coverage", ".hidden"]; + writeFileSync(valid, "source\n"); + + for (const directory of ignored) { + mkdirSync(join(root, directory), { recursive: true }); + writeFileSync(join(root, directory, `${directory.replaceAll(".", "")}.sdp.ts`), "ignored\n"); + } + symlinkSync(root, join(root, "cycle")); + + try { + const result = runSdpCli(["import", root], captureOutput().output, { + import: { importTypeScriptSpec: (_source, path) => emitted(path) }, + }); + + expect(result).toBe(0); + expect(existsSync(join(root, "valid.sdp.md"))).toBe(true); + for (const directory of ignored) { + expect(existsSync(join(root, directory, `${directory.replaceAll(".", "")}.sdp.md`))).toBe( + false, + ); + } + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("collects every source refusal and publishes nothing", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-collect-")); + for (const name of ["bad-a", "bad-b", "bad-c", "good"]) { + writeFileSync(join(root, `${name}.sdp.ts`), "source\n"); + } + + try { + const capture = captureOutput(); + const result = runSdpCli(["import", root], capture.output, { + import: { + importTypeScriptSpec: (_source, path) => + path.includes("bad-") ? refused(path) : emitted(path), + }, + }); + + expect(result).toBe(1); + expect(capture.stderr().match(/import\/refusal/gu)).toHaveLength(3); + expect(existsSync(join(root, "good.sdp.md"))).toBe(false); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("collects a canonicalization failure and still plans healthy siblings", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-canonical-failure-")); + const failedSource = join(root, "a.sdp.ts"); + const healthySource = join(root, "b.sdp.ts"); + const importedPaths: string[] = []; + writeFileSync(failedSource, "a\n"); + writeFileSync(healthySource, "b\n"); + + try { + const capture = captureOutput(); + const result = runSdpCli(["import", root], capture.output, { + import: { + realpathSync: (path) => { + if (path === failedSource) { + throw new Error("canonicalization denied"); + } + return realpathSync(path); + }, + importTypeScriptSpec: (_source, path) => { + importedPaths.push(path); + return emitted(path); + }, + }, + }); + + expect(result).toBe(1); + expect(capture.stderr()).toContain(`${failedSource} — canonicalization denied`); + expect(importedPaths).toEqual([realpathSync(healthySource)]); + expect(existsSync(join(root, "b.sdp.md"))).toBe(false); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("deduplicates physical sources before dry-run and publication", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-dedupe-")); + const realDirectory = join(root, "real"); + const aliasDirectory = join(root, "alias"); + mkdirSync(realDirectory); + const canonicalDirectory = realpathSync(realDirectory); + writeFileSync(join(realDirectory, "order.sdp.ts"), "source\n"); + symlinkSync(realDirectory, aliasDirectory); + + try { + const dryRun = captureOutput(); + const dryCode = runSdpCli( + [ + "import", + join(aliasDirectory, "order.sdp.ts"), + join(realDirectory, "order.sdp.ts"), + "--dry-run", + ], + dryRun.output, + { import: { importTypeScriptSpec: (_source, path) => emitted(path) } }, + ); + const writeCode = runSdpCli( + ["import", join(aliasDirectory, "order.sdp.ts"), join(realDirectory, "order.sdp.ts")], + captureOutput().output, + { import: { importTypeScriptSpec: (_source, path) => emitted(path) } }, + ); + + expect(dryCode).toBe(0); + expect(dryRun.stdout().match(/^=== /gmu)).toHaveLength(1); + expect(dryRun.stdout()).toContain(join(canonicalDirectory, "order.sdp.md")); + expect(dryRun.stdout()).not.toContain(join(aliasDirectory, "order.sdp.md")); + expect(writeCode).toBe(0); + expect(readFileSync(join(realDirectory, "order.sdp.md"), "utf8")).toBe("# Imported\n"); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("reports an explicit non-carrier operand and withholds valid siblings", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-operand-")); + const valid = join(root, "valid.sdp.ts"); + const typo = join(root, "order.spd.ts"); + writeFileSync(valid, "source\n"); + writeFileSync(typo, "typo\n"); + + try { + const capture = captureOutput(); + const result = runSdpCli(["import", valid, typo], capture.output, { + import: { importTypeScriptSpec: (_source, path) => emitted(path) }, + }); + + expect(result).toBe(1); + expect(capture.stderr()).toContain("import/invalid-source-path"); + expect(capture.stderr()).toContain(typo); + expect(existsSync(join(root, "valid.sdp.md"))).toBe(false); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("reports the publish error and every rollback survivor without throwing", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-rollback-")); + const first = join(root, "a.sdp.ts"); + const second = join(root, "b.sdp.ts"); + const firstTarget = join(realpathSync(root), "a.sdp.md"); + writeFileSync(first, "a\n"); + writeFileSync(second, "b\n"); + let publishes = 0; + + try { + const capture = captureOutput(); + const result = runSdpCli(["import", root], capture.output, { + import: { + importTypeScriptSpec: (_source, path) => emitted(path), + linkSync: (from, to) => { + publishes += 1; + if (publishes === 2) throw new Error("publish failed"); + linkSync(from, to); + }, + rmSync: (path, options) => { + if (path === firstTarget) + throw Object.assign(new Error("removal denied"), { code: "EACCES" }); + rmSync(path, options); + }, + }, + }); + + expect(result).toBe(1); + expect(capture.stderr()).toContain("publish failed"); + expect(capture.stderr()).toContain(`stale ${firstTarget} could not be removed`); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("does not overwrite a target created during publication", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-race-")); + const source = join(root, "order.sdp.ts"); + const target = join(root, "order.sdp.md"); + writeFileSync(source, "source\n"); + + try { + const result = runSdpCli(["import", source], captureOutput().output, { + import: { + importTypeScriptSpec: (_source, path) => emitted(path), + linkSync: (from, to) => { + writeFileSync(to, "# Concurrent writer\n"); + linkSync(from, to); + }, + }, + }); + + expect(result).toBe(1); + expect(readFileSync(target, "utf8")).toBe("# Concurrent writer\n"); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("preserves a pre-existing temporary path that this invocation did not create", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-temp-owner-")); + const source = join(root, "order.sdp.ts"); + const canonicalRoot = realpathSync(root); + const target = join(canonicalRoot, "order.sdp.md"); + const temporary = `${target}.sdp-import-${String(process.pid)}-0.tmp`; + writeFileSync(source, "source\n"); + writeFileSync(temporary, "foreign temporary\n"); + + try { + const result = runSdpCli(["import", source], captureOutput().output, { + import: { importTypeScriptSpec: (_source, path) => emitted(path) }, + }); + + expect(result).toBe(1); + expect(readFileSync(temporary, "utf8")).toBe("foreign temporary\n"); + expect(existsSync(target)).toBe(false); + expect(existsSync(source)).toBe(true); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("treats operands after -- as paths", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-terminator-")); + const source = join(root, "--carrier.sdp.ts"); + writeFileSync(source, "source\n"); + + try { + const result = runSdpCli(["import", "--", source], captureOutput().output, { + import: { importTypeScriptSpec: (_source, path) => emitted(path) }, + }); + + expect(result).toBe(0); + expect(existsSync(join(root, "--carrier.sdp.md"))).toBe(true); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("deduplicates hardlink operands by physical identity during dry-run", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-hardlink-dedupe-")); + const original = join(root, "original.sdp.ts"); + const hardlink = join(root, "hardlink.sdp.ts"); + writeFileSync(original, "source\n"); + linkSync(original, hardlink); + + try { + const dryRun = captureOutput(); + const result = runSdpCli(["import", original, hardlink, "--dry-run"], dryRun.output, { + import: { importTypeScriptSpec: (_source, path) => emitted(path) }, + }); + + expect(result).toBe(0); + expect(dryRun.stdout().match(/^=== /gmu)).toHaveLength(1); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it.skipIf(!caseInsensitive)( + "deduplicates case-alias operands before dry-run and publication", + () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-case-alias-dedupe-")); + const source = join(root, "CaseAlias.sdp.ts"); + const alias = join(root, "casealias.sdp.ts"); + writeFileSync(source, "source\n"); + + try { + const dryRun = captureOutput(); + const dryRunResult = runSdpCli(["import", source, alias, "--dry-run"], dryRun.output, { + import: { importTypeScriptSpec: (_source, path) => emitted(path) }, + }); + const publication = captureOutput(); + const publicationResult = runSdpCli(["import", source, alias], publication.output, { + import: { importTypeScriptSpec: (_source, path) => emitted(path) }, + }); + + expect(dryRunResult).toBe(0); + expect(dryRun.stdout().match(/^=== /gmu)).toHaveLength(1); + expect(publicationResult).toBe(0); + expect(existsSync(realpathSync(source).replace(/\.sdp\.ts$/u, ".sdp.md"))).toBe(true); + expect(publication.stderr()).not.toContain("EEXIST"); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }, + ); + + it("plans distinct physical operands independently during dry-run", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-distinct-dedupe-")); + const first = join(root, "first.sdp.ts"); + const second = join(root, "second.sdp.ts"); + writeFileSync(first, "first\n"); + writeFileSync(second, "second\n"); + + try { + const dryRun = captureOutput(); + const result = runSdpCli(["import", first, second, "--dry-run"], dryRun.output, { + import: { importTypeScriptSpec: (_source, path) => emitted(path) }, + }); + + expect(result).toBe(0); + expect(dryRun.stdout().match(/^=== /gmu)).toHaveLength(2); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("publishes valid sources when an empty directory produces only a warning", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-warning-batch-")); + const emptyDirectory = join(root, "empty"); + const valid = join(root, "valid.sdp.ts"); + mkdirSync(emptyDirectory); + writeFileSync(valid, "source\n"); + + try { + const capture = captureOutput(); + const result = runSdpCli(["import", emptyDirectory, valid], capture.output, { + import: { importTypeScriptSpec: (_source, path) => emitted(path) }, + }); + + expect(result).toBe(0); + expect(capture.stderr()).toContain("import/no-sources"); + expect(readFileSync(join(root, "valid.sdp.md"), "utf8")).toBe("# Imported\n"); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("fails an empty directory because it produces no import plan", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-empty-plan-")); + const emptyDirectory = join(root, "empty"); + mkdirSync(emptyDirectory); + + try { + const capture = captureOutput(); + const result = runSdpCli(["import", emptyDirectory], capture.output, { + import: { importTypeScriptSpec: (_source, path) => emitted(path) }, + }); + + expect(result).toBe(1); + expect(capture.stderr()).toContain("import/no-sources"); + expect(capture.stdout()).toBe(""); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("withholds valid sources when an explicit non-carrier operand is an error", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-error-batch-")); + const valid = join(root, "valid.sdp.ts"); + const invalid = join(root, "invalid.ts"); + writeFileSync(valid, "source\n"); + writeFileSync(invalid, "invalid\n"); + + try { + const capture = captureOutput(); + const result = runSdpCli(["import", valid, invalid], capture.output, { + import: { importTypeScriptSpec: (_source, path) => emitted(path) }, + }); + + expect(result).toBe(1); + expect(capture.stderr()).toContain("import/invalid-source-path"); + expect(existsSync(join(root, "valid.sdp.md"))).toBe(false); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("withholds valid sources when an operand cannot be stat'ed", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-operational-batch-")); + const valid = join(root, "valid.sdp.ts"); + const missing = join(root, "missing.sdp.ts"); + writeFileSync(valid, "source\n"); + + try { + const capture = captureOutput(); + const result = runSdpCli(["import", valid, missing], capture.output, { + import: { importTypeScriptSpec: (_source, path) => emitted(path) }, + }); + + expect(result).toBe(1); + expect(capture.stderr()).toContain(missing); + expect(existsSync(join(root, "valid.sdp.md"))).toBe(false); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("keeps a published target when its temporary cleanup fails", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-temp-cleanup-")); + const source = join(root, "order.sdp.ts"); + const canonicalRoot = realpathSync(root); + const target = join(canonicalRoot, "order.sdp.md"); + const temporary = `${target}.sdp-import-${String(process.pid)}-0.tmp`; + writeFileSync(source, "source\n"); + + try { + const capture = captureOutput(); + const result = runSdpCli(["import", source], capture.output, { + import: { + importTypeScriptSpec: (_source, path) => emitted(path), + rmSync: (path, options) => { + if (path === temporary) throw new Error("temporary removal denied"); + rmSync(path, options); + }, + }, + }); + + expect(result).toBe(0); + expect(existsSync(target)).toBe(true); + expect(capture.stderr()).toMatch(/stale .* could not be removed/u); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("reports unavailable hard-link support and rolls back publication", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-import-hard-link-support-")); + const first = join(root, "a.sdp.ts"); + const second = join(root, "b.sdp.ts"); + const canonicalRoot = realpathSync(root); + const firstTarget = join(canonicalRoot, "a.sdp.md"); + const secondTarget = join(canonicalRoot, "b.sdp.md"); + const firstTemporary = `${firstTarget}.sdp-import-${String(process.pid)}-0.tmp`; + const secondTemporary = `${secondTarget}.sdp-import-${String(process.pid)}-1.tmp`; + writeFileSync(first, "first\n"); + writeFileSync(second, "second\n"); + let attempts = 0; + + try { + const capture = captureOutput(); + const result = runSdpCli(["import", first, second], capture.output, { + import: { + importTypeScriptSpec: (_source, path) => emitted(path), + linkSync: (from, to) => { + attempts += 1; + if (attempts === 2) { + throw Object.assign(new Error("cross-device link"), { code: "EXDEV" }); + } + linkSync(from, to); + }, + }, + }); + + expect(result).toBe(1); + expect(capture.stderr()).toContain("hard-link support"); + expect(existsSync(firstTarget)).toBe(false); + expect(existsSync(secondTarget)).toBe(false); + expect(existsSync(firstTemporary)).toBe(false); + expect(existsSync(secondTemporary)).toBe(false); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("documents the hard-link requirement in import help", () => { + const capture = captureOutput(); + const result = runSdpCli(["--help"], capture.output); + + expect(result).toBe(0); + expect(capture.stdout()).toContain("hard link"); + }); +}); diff --git a/test/cli-import.test.ts b/test/cli-import.test.ts new file mode 100644 index 0000000..232fd48 --- /dev/null +++ b/test/cli-import.test.ts @@ -0,0 +1,259 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import type { ImportResult } from "../src/index.js"; +import { SDP_HELP_TEXT, runSdpCli } from "../src/cli/sdp.js"; + +function createCaptureOutput() { + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + + return { + output: { + stdout: { write: (chunk: string) => stdoutChunks.push(chunk) }, + stderr: { write: (chunk: string) => stderrChunks.push(chunk) }, + }, + readStdout: () => stdoutChunks.join(""), + readStderr: () => stderrChunks.join(""), + }; +} + +function emittedImport(relativePath: string): ImportResult { + return { + emitted: { + path: relativePath.replace(/\.sdp\.ts$/u, ".sdp.md"), + content: "# Imported Spec\n", + }, + findings: [], + }; +} + +describe("sdp import", () => { + it("documents the import verb", () => { + // Given: the public CLI help surface. + // When: consumers inspect the command list. + // Then: the durable import invocation is discoverable. + expect(SDP_HELP_TEXT).toContain("sdp import [--dry-run]"); + }); + + it("prints would-be Markdown without writing when dry-run is requested", () => { + // Given: one source document under a disposable root. + const root = mkdtempSync(join(tmpdir(), "sdp-import-dry-run-")); + const sourcePath = join(root, "specs", "order.sdp.ts"); + mkdirSync(join(root, "specs"), { recursive: true }); + writeFileSync(sourcePath, "source\n", "utf8"); + + try { + const capture = createCaptureOutput(); + + // When: import runs in preview mode. + const exitCode = runSdpCli(["import", root, "--dry-run"], capture.output, { + import: { importTypeScriptSpec: (_source, relativePath) => emittedImport(relativePath) }, + }); + + // Then: it emits the document to stdout and leaves no sibling behind. + expect(exitCode).toBe(0); + expect(capture.readStdout()).toBe( + `=== ${join(realpathSync(root), "specs", "order.sdp.md")} ===\n# Imported Spec\n`, + ); + expect(existsSync(join(root, "specs", "order.sdp.md"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("writes an imported document beside its TypeScript source", () => { + // Given: a nested TypeScript carrier. + const root = mkdtempSync(join(tmpdir(), "sdp-import-write-beside-")); + const sourcePath = join(root, "nested", "order.sdp.ts"); + mkdirSync(join(root, "nested"), { recursive: true }); + writeFileSync(sourcePath, "source\n", "utf8"); + + try { + // When: import receives the root. + const exitCode = runSdpCli(["import", root], createCaptureOutput().output, { + import: { importTypeScriptSpec: (_source, relativePath) => emittedImport(relativePath) }, + }); + + // Then: the Markdown sibling lands and the TypeScript source remains. + expect(exitCode).toBe(0); + expect(readFileSync(join(root, "nested", "order.sdp.md"), "utf8")).toBe("# Imported Spec\n"); + expect(existsSync(sourcePath)).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("refuses an existing Markdown target without overwriting it", () => { + // Given: a TypeScript carrier whose sibling already exists. + const root = mkdtempSync(join(tmpdir(), "sdp-import-target-exists-")); + const sourcePath = join(root, "order.sdp.ts"); + const targetPath = join(root, "order.sdp.md"); + writeFileSync(sourcePath, "source\n", "utf8"); + writeFileSync(targetPath, "# Existing\n", "utf8"); + + try { + const capture = createCaptureOutput(); + + // When: import would write that sibling. + const exitCode = runSdpCli(["import", sourcePath], capture.output, { + import: { importTypeScriptSpec: (_source, relativePath) => emittedImport(relativePath) }, + }); + + // Then: it reports the refusal and preserves the existing content. + expect(exitCode).toBe(1); + expect(capture.readStderr()).toContain("import/target-exists"); + expect(readFileSync(targetPath, "utf8")).toBe("# Existing\n"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("leaves every target absent when one carrier is refused", () => { + // Given: a Pack carrier followed by a valid Spec carrier. + const root = mkdtempSync(join(tmpdir(), "sdp-import-pack-")); + const packPath = join(root, "bundle.pack.sdp.ts"); + const specPath = join(root, "order.sdp.ts"); + writeFileSync(packPath, "pack\n", "utf8"); + writeFileSync(specPath, "source\n", "utf8"); + + try { + const capture = createCaptureOutput(); + + // When: the root is imported. + const exitCode = runSdpCli(["import", root], capture.output, { + import: { + importTypeScriptSpec: (_source, relativePath) => + relativePath.endsWith(".pack.sdp.ts") + ? { + findings: [ + { + validatorId: "import/pack-unsupported", + family: "conformance", + severity: "error" as const, + message: "Pack remains TypeScript-authored.", + file: relativePath, + line: 1, + }, + ], + } + : emittedImport(relativePath), + }, + }); + + // Then: the Pack is named and no partial import is published. + expect(exitCode).toBe(1); + expect(capture.readStderr()).toContain("import/pack-unsupported"); + expect(existsSync(join(root, "order.sdp.md"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects an unknown import flag by name", () => { + // Given: an unrecognized import option. + const capture = createCaptureOutput(); + + // When: the parser receives it. + const exitCode = runSdpCli(["import", "--exclude", "--foo"], capture.output); + + // Then: invocation fails before any import work begins. + expect(exitCode).toBe(1); + expect(capture.readStderr()).toBe("sdp import: unknown option --exclude\n"); + }); + + it("refuses a requested path with no TypeScript carriers", () => { + // Given: an explicitly requested ordinary file. + const root = mkdtempSync(join(tmpdir(), "sdp-import-no-sources-")); + const sourcePath = join(root, "README.md"); + writeFileSync(sourcePath, "# Readme\n", "utf8"); + + try { + const capture = createCaptureOutput(); + + // When: import receives no carrier source. + const exitCode = runSdpCli(["import", sourcePath], capture.output); + + // Then: it reports the failed request rather than claiming success. + expect(exitCode).toBe(1); + expect(capture.readStderr()).toContain("import/invalid-source-path"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("renders refusal findings and exits 1", () => { + // Given: a source whose adapter refuses to emit. + const root = mkdtempSync(join(tmpdir(), "sdp-import-refusal-")); + const sourcePath = join(root, "refused.sdp.ts"); + writeFileSync(sourcePath, "source\n", "utf8"); + + try { + const capture = createCaptureOutput(); + + // When: import receives the refused source. + const exitCode = runSdpCli(["import", sourcePath], capture.output, { + import: { + importTypeScriptSpec: (_source, relativePath) => ({ + findings: [ + { + validatorId: "import/refusal", + family: "conformance", + severity: "error" as const, + message: "The source was refused.", + file: relativePath, + line: 1, + }, + ], + }), + }, + }); + + // Then: the shared finding renderer names the refusal and the command fails. + expect(exitCode).toBe(1); + expect(capture.readStderr()).toContain("[error] import/refusal"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("renders an injected write failure as one operational error line", () => { + // Given: a writable source and a deterministic denied-write producer. + const root = mkdtempSync(join(tmpdir(), "sdp-import-write-error-")); + const sourcePath = join(root, "order.sdp.ts"); + writeFileSync(sourcePath, "source\n", "utf8"); + + try { + const capture = createCaptureOutput(); + + // When: the CLI writes the emitted sibling through the injected filesystem seam. + const exitCode = runSdpCli(["import", sourcePath], capture.output, { + import: { + importTypeScriptSpec: (_source, relativePath) => emittedImport(relativePath), + writeFileSync: () => { + throw new Error("EACCES: permission denied, open 'order.sdp.md'"); + }, + }, + }); + + // Then: the boundary reports the operational failure without a stack trace. + expect(exitCode).toBe(1); + expect(capture.readStderr()).toBe( + "sdp import: EACCES: permission denied, open 'order.sdp.md'\n", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/cli.test.ts b/test/cli.test.ts index 673d30c..1cee15d 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -162,7 +162,16 @@ describe("sdp cli", () => { const capture = createCaptureOutput(); const exitCode = runSdpCli( - ["view", "--check-clean", "--exclude", "explorations", "--exclude", "examples"], + [ + "view", + "--check-clean", + "--exclude", + "explorations", + "--exclude", + "examples", + "--exclude", + "test/fixtures/import/parity", + ], capture.output, ); @@ -176,7 +185,15 @@ describe("sdp cli", () => { } finally { expect( runSdpCli( - ["view", "--exclude", "explorations", "--exclude", "examples"], + [ + "view", + "--exclude", + "explorations", + "--exclude", + "examples", + "--exclude", + "test/fixtures/import/parity", + ], createCaptureOutput().output, ), ).toBe(0); @@ -472,6 +489,26 @@ export const example${idSegment.replace(/[^A-Za-z0-9]/gu, "")} = spec({ } }); + it("names a flag supplied as an --exclude operand before build writes", () => { + // Given: a writable empty root and a flag where an exclusion path is required. + const root = mkdtempSync(join(tmpdir(), "sdp-flag-exclude-")); + + try { + const capture = createCaptureOutput(); + + // When: the option consumes another flag instead of a path. + const exitCode = runSdpCli(["build", root, "--exclude", "--foo"], capture.output); + + // Then: the usage error identifies the offending flag and no artifact directory is created. + expect(exitCode).toBe(1); + expect(capture.readStdout()).toBe(""); + expect(capture.readStderr()).toBe("sdp build: --exclude expects a path, got --foo\n"); + expect(existsSync(join(root, "generated"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("documents repeatable --exclude paths in help", () => { expect(SDP_HELP_TEXT).toContain("[--exclude PATH]..."); expect(SDP_HELP_TEXT).toContain("*.sdp.ts and *.sdp.md"); diff --git a/test/design-review-review-08.test.ts b/test/design-review-review-08.test.ts new file mode 100644 index 0000000..1f39564 --- /dev/null +++ b/test/design-review-review-08.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { createReader, renderDesignReview, schemaVersion, spec, specId } from "../src/index.js"; +import type { DesignReviewPage, GraphSchema } from "../src/index.js"; +import { renderInlineCode } from "../src/projections/design-review-markdown.js"; +import { deriveFixtureGraph } from "./helpers/fixture-graph.js"; + +function pageByPath(pages: readonly DesignReviewPage[], path: string): string { + const page = pages.find((entry) => entry.path === path); + + if (page === undefined) { + throw new Error(`Missing page ${path}`); + } + + return page.content; +} + +describe("review-08 Design Review rendering", () => { + it("preserves authored boundary spaces inside inline code", () => { + expect(renderInlineCode(" padded ")).toBe("` padded `"); + }); + + it("preserves fenced JSON data while sorting raw keys by code unit", () => { + const graph = deriveFixtureGraph({ + specs: [ + spec({ + id: specId("spec:orders.literal-json"), + title: "Literal JSON", + kind: "behavior", + altitude: "story", + readiness: "idea", + design: { + "a|x": "Review & safely.", + ab: "Keep `code` literal.", + }, + }), + ], + }); + const page = pageByPath(renderDesignReview(createReader(graph)), "spec/orders.literal-json.md"); + const fenced = /```json\n([\s\S]*?)\n```/u.exec(page)?.[1]; + + expect(fenced).toBeDefined(); + expect(JSON.parse(fenced ?? "{}")).toEqual({ + ab: "Keep `code` literal.", + "a|x": "Review & safely.", + }); + expect(fenced?.indexOf('"ab"')).toBeLessThan(fenced?.indexOf('"a|x"') ?? -1); + }); + + it("preserves literal finding locations inside delimiter-safe table code spans", () => { + const graph: GraphSchema = { + schemaVersion, + nodes: [ + { + id: "spec:orders.literal-location", + nodeType: "Primitive", + claim: "declared", + specKind: "behavior", + altitude: "story", + readiness: "idea", + title: "Literal location", + file: "specs/`location<&|\ncontinuation.sdp.md", + }, + ], + edges: [ + { + from: "spec:orders.literal-location", + type: "dependsOn", + to: "spec:orders.missing", + claim: "declared", + }, + ], + }; + const page = pageByPath( + renderDesignReview(createReader(graph)), + "spec/orders.literal-location.md", + ); + const findingRows = page + .split("\n") + .filter((line) => line.includes("conformance/referential-integrity")); + const findingRow = findingRows[0]; + + expect(findingRows).toHaveLength(1); + expect(findingRow).toContain("specs/`location<&\\| continuation.sdp.md"); + expect(findingRow).not.toContain("<"); + expect(findingRow).not.toContain("&"); + expect(findingRow).toContain("\\|"); + }); +}); diff --git a/test/design-review.test.ts b/test/design-review.test.ts index 1910060..0e81744 100644 --- a/test/design-review.test.ts +++ b/test/design-review.test.ts @@ -273,6 +273,89 @@ describe("the Design Review — the one generated read-only view", () => { expect(again).toEqual(examplePages); }); + it("renders byte-identically when graph and dynamic-key insertion orders differ", () => { + const first = deriveFixtureGraph({ + specs: [ + spec({ + id: specId("spec:orders.dynamic-order"), + title: "Dynamic order", + kind: "model", + altitude: "story", + readiness: "idea", + intent: { outcome: "Render dynamic fields deterministically." }, + model: { terms: { Zebra: "Last term.", Alpha: "First term." } }, + design: { zeta: "last value", alpha: "first value" }, + }), + spec({ + id: specId("spec:orders.peer"), + title: "Peer", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "Vary graph node insertion order." }, + }), + ], + }); + const permuted = deriveFixtureGraph({ + specs: [ + spec({ + id: specId("spec:orders.peer"), + title: "Peer", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "Vary graph node insertion order." }, + }), + spec({ + id: specId("spec:orders.dynamic-order"), + title: "Dynamic order", + kind: "model", + altitude: "story", + readiness: "idea", + intent: { outcome: "Render dynamic fields deterministically." }, + model: { terms: { Alpha: "First term.", Zebra: "Last term." } }, + design: { alpha: "first value", zeta: "last value" }, + }), + ], + }); + + const firstPages = renderDesignReview(createReader(first)); + const permutedPages = renderDesignReview(createReader(permuted)); + + expect(permutedPages).toEqual(firstPages); + }); + + it("escapes Markdown text fields while preserving fenced JSON data", () => { + const graph = deriveFixtureGraph({ + specs: [ + spec({ + id: specId("spec:orders.escaping"), + title: "Render & fields", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "Keep & visible." }, + behavior: { rules: ["Keep & stable."] }, + model: { terms: { " &": "Define & safely." } }, + design: { surface: "Review & safely." }, + verification: { + mode: "reviewed", + criteria: ["Inspect & output."], + }, + }), + ], + }); + + const page = pageByPath(renderDesignReview(createReader(graph)), "spec/orders.escaping.md"); + + expect(page).toContain("# Render <tag> & fields"); + expect(page).toContain("- **outcome:** Keep <outcome> & visible."); + expect(page).toContain("- Keep <rule> & stable."); + expect(page).toContain("| <Term> & | Define <term> & safely. |"); + expect(page).toContain('"surface": "Review & safely."'); + expect(page).toContain("- Inspect <criterion> & output."); + }); + it("speaks binding language, never liveness: bindings present/none, observation not tracked", () => { const createOrder = pageByPath(examplePages, "spec/orders.create-order.md"); @@ -317,7 +400,7 @@ describe("the Design Review — the one generated read-only view", () => { expect(index).toContain("| Severity | Check | Message | Where |"); // The standing warning's subject is a spec file: `file` known, no line (Primitive nodes are // line-free by design), and the location is never embedded in the message a second time. - expect(index).toContain("| `specs/orders/create-order-invalid-cart.sdp.ts` |"); + expect(index).toContain("| `specs/orders/create-order-invalid-cart.sdp.md` |"); }); it("shows what a verifier covers on its own page (JS-G2: from the test back to the spec)", () => { diff --git a/test/exclude-diagnostics.test.ts b/test/exclude-diagnostics.test.ts new file mode 100644 index 0000000..1727fb9 --- /dev/null +++ b/test/exclude-diagnostics.test.ts @@ -0,0 +1,60 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { discoverFiles, normalizeExcludes } from "../src/extract/discover.js"; + +const temporaryRoots: string[] = []; + +function temporaryRoot(): string { + const root = mkdtempSync(join(tmpdir(), "sdp-exclude-diagnostics-")); + temporaryRoots.push(root); + return root; +} + +describe("exclusion diagnostics", () => { + afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects a Windows drive-letter absolute path instead of accepting an unmatched prefix", () => { + // Given: a Windows absolute path, which cannot identify a root-relative POSIX prefix. + const windowsAbsolute = "C:/work/specs"; + + // When / Then: the library boundary rejects it loudly. + expect(() => normalizeExcludes([windowsAbsolute])).toThrow( + 'normalizeExcludes: invalid exclusion path "C:/work/specs"', + ); + }); + + it("keeps the library diagnostic distinct from CLI flag wording", () => { + // Given: a malformed library exclusion value. + const invalidPath = "./specs"; + + // When / Then: the reusable normalizer names its own boundary, not a CLI flag. + expect(() => normalizeExcludes([invalidPath])).toThrow( + 'normalizeExcludes: invalid exclusion path "./specs"', + ); + }); + + it("excludes only an exact prefix or a path-segment descendant", () => { + // Given: sibling paths where one merely begins with the other prefix's code units. + const root = temporaryRoot(); + mkdirSync(join(root, "foo")); + mkdirSync(join(root, "foobar")); + writeFileSync(join(root, "foo", "excluded.sdp.ts"), "", "utf8"); + writeFileSync(join(root, "foobar", "included.sdp.ts"), "", "utf8"); + + // When: only the `foo` prefix is excluded. + const discovered = discoverFiles(root, ["foo"]); + + // Then: `foo` is absent, while `foobar` remains because a slash segment boundary is required. + expect(discovered.specFiles.map((file) => file.relativePath)).toEqual([ + "foobar/included.sdp.ts", + ]); + }); +}); diff --git a/test/extract-parity.test.ts b/test/extract-parity.test.ts new file mode 100644 index 0000000..ab545ed --- /dev/null +++ b/test/extract-parity.test.ts @@ -0,0 +1,214 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Project } from "ts-morph"; +import { describe, expect, it } from "vitest"; + +import { reifyAnchorSourceFile } from "../src/extract/anchors.js"; +import { + extract, + extractFindingIds, + reifyMarkdownCarrier, + reifyTypeScriptCarrier, +} from "../src/index.js"; +import type { Finding } from "../src/validate/contracts.js"; + +const fixtureRoot = new URL("./fixtures/import/parity/", import.meta.url); + +type FindingId = (typeof extractFindingIds)[keyof typeof extractFindingIds]; +type MarkdownFindingId = + | FindingId + | "extract/invalid-frontmatter" + | "extract/invalid-markdown-structure"; +type TypeScriptReifier = "carrier" | "anchor"; +type MatrixOutcome = "same-class" | "named-non-claim"; + +interface MatrixCell { + readonly id: string; + readonly typeScriptFinding: FindingId; + readonly markdownFinding: MarkdownFindingId; + readonly typeScriptReifier: TypeScriptReifier; + readonly outcome: MatrixOutcome; + readonly rationale?: string; +} + +const matrix = [ + { + id: "parse-error", + typeScriptFinding: extractFindingIds.parseError, + markdownFinding: "extract/invalid-frontmatter", + typeScriptReifier: "carrier", + outcome: "named-non-claim", + rationale: "YAML/frontmatter parsing has no TypeScript parser-diagnostic analogue.", + }, + { + id: "non-static-envelope", + typeScriptFinding: extractFindingIds.nonStaticEnvelope, + markdownFinding: extractFindingIds.nonStaticEnvelope, + typeScriptReifier: "carrier", + outcome: "same-class", + }, + { + id: "invalid-id", + typeScriptFinding: extractFindingIds.invalidId, + markdownFinding: extractFindingIds.invalidId, + typeScriptReifier: "carrier", + outcome: "same-class", + }, + { + id: "duplicate-id", + typeScriptFinding: extractFindingIds.duplicateId, + markdownFinding: extractFindingIds.duplicateId, + typeScriptReifier: "carrier", + outcome: "same-class", + }, + { + id: "reserved-property", + typeScriptFinding: extractFindingIds.reservedProperty, + markdownFinding: extractFindingIds.reservedProperty, + typeScriptReifier: "carrier", + outcome: "same-class", + }, + { + id: "non-static-section", + typeScriptFinding: extractFindingIds.nonStaticSection, + markdownFinding: "extract/invalid-markdown-structure", + typeScriptReifier: "carrier", + outcome: "named-non-claim", + rationale: + "TS degrades optional section properties while Markdown deliberately refuses malformed documents whole.", + }, + { + id: "unowned-prose", + typeScriptFinding: extractFindingIds.unownedProse, + markdownFinding: extractFindingIds.unownedProse, + typeScriptReifier: "carrier", + outcome: "same-class", + }, + { + id: "unrecognized-statement", + typeScriptFinding: extractFindingIds.unrecognizedStatement, + markdownFinding: "extract/invalid-markdown-structure", + typeScriptReifier: "carrier", + outcome: "named-non-claim", + rationale: "Markdown owns prose and structures, not TypeScript statement recognition.", + }, + { + id: "unrecognized-property", + typeScriptFinding: extractFindingIds.unrecognizedProperty, + markdownFinding: extractFindingIds.unrecognizedProperty, + typeScriptReifier: "carrier", + outcome: "same-class", + }, + { + id: "misplaced-authoring", + typeScriptFinding: extractFindingIds.misplacedAuthoring, + markdownFinding: "extract/invalid-markdown-structure", + typeScriptReifier: "anchor", + outcome: "named-non-claim", + rationale: + "Markdown has no executable authoring-call surface; equivalent placement is document structure.", + }, +] as const satisfies readonly MatrixCell[]; + +function findingIds(findings: readonly Finding[]): readonly string[] { + return findings.map((finding) => finding.validatorId); +} + +function reifyTypeScriptProbe( + sourceText: string, + relativePath: string, + reifier: TypeScriptReifier, +): { readonly findings: readonly Finding[] } { + switch (reifier) { + case "carrier": + return reifyTypeScriptCarrier(sourceText, relativePath); + case "anchor": { + const project = new Project({ + useInMemoryFileSystem: true, + compilerOptions: { noLib: true }, + }); + return reifyAnchorSourceFile( + project.createSourceFile(relativePath, sourceText), + relativePath, + ); + } + } +} + +function readProbePair(id: string): { readonly typeScript: string; readonly markdown: string } { + return { + typeScript: readFileSync(new URL(`${id}.sdp.ts`, fixtureRoot), "utf8"), + markdown: readFileSync(new URL(`${id}.sdp.md`, fixtureRoot), "utf8"), + }; +} + +function duplicateFindingIds(pair: { + readonly typeScript: string; + readonly markdown: string; +}): readonly string[] { + const root = mkdtempSync(join(tmpdir(), "sdp-refusal-parity-")); + try { + writeFileSync(join(root, "duplicate-id.sdp.ts"), pair.typeScript, "utf8"); + writeFileSync(join(root, "duplicate-id.sdp.md"), pair.markdown, "utf8"); + return findingIds(extract({ root }).report.findings); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +describe("the bounded TypeScript and Markdown refusal-parity matrix", () => { + it.each(matrix)("records %s", (cell) => { + // Given + const pair = readProbePair(cell.id); + const typeScript = reifyTypeScriptProbe( + pair.typeScript, + `${cell.id}.sdp.ts`, + cell.typeScriptReifier, + ); + const markdown = reifyMarkdownCarrier(pair.markdown, `${cell.id}.sdp.md`); + + // When + const typeScriptIds = findingIds(typeScript.findings); + const markdownIds = findingIds(markdown.findings); + + // Then + if (cell.id === "duplicate-id") { + expect(typeScriptIds).toEqual([]); + expect(markdownIds).toEqual([]); + expect(duplicateFindingIds(pair)).toEqual([ + extractFindingIds.duplicateId, + extractFindingIds.duplicateId, + ]); + return; + } + + expect(typeScriptIds).toContain(cell.typeScriptFinding); + expect(markdownIds).toContain(cell.markdownFinding); + if (cell.outcome === "same-class") { + expect(cell.markdownFinding).toBe(cell.typeScriptFinding); + return; + } + + expect(cell.markdownFinding).not.toBe(cell.typeScriptFinding); + expect(cell.rationale).toBeDefined(); + }); + + it("records finding-class parity without claiming equal severity or extraction outcome", () => { + const pair = readProbePair("unrecognized-property"); + const typeScript = reifyTypeScriptCarrier(pair.typeScript, "unrecognized-property.sdp.ts"); + const markdown = reifyMarkdownCarrier(pair.markdown, "unrecognized-property.sdp.md"); + const typeScriptFinding = typeScript.findings.find( + (finding) => finding.validatorId === extractFindingIds.unrecognizedProperty, + ); + const markdownFinding = markdown.findings.find( + (finding) => finding.validatorId === extractFindingIds.unrecognizedProperty, + ); + + expect(typeScriptFinding?.severity).toBe("warning"); + expect(typeScript.specs).toHaveLength(1); + expect(markdownFinding?.severity).toBe("error"); + expect(markdown.specs).toEqual([]); + }); +}); diff --git a/test/extract.test.ts b/test/extract.test.ts index 8bba2ad..f368013 100644 --- a/test/extract.test.ts +++ b/test/extract.test.ts @@ -338,6 +338,103 @@ export const carrier = spec({ ]); }); + it("reserved camel delivery fact: rejects hasVerifier at the TS carrier envelope (R-6 parity)", () => { + const reified = reifyTypeScriptCarrier( + `import { spec, specId } from "@libar-dev/software-delivery-protocol"; +export const carrier = spec({ + id: specId("spec:orders.reserved-camel-verifier"), + kind: "behavior", + altitude: "story", + readiness: "idea", + hasVerifier: true, +});`, + "reserved-camel-verifier.sdp.ts", + ); + + expect(reified.specs).toEqual([]); + expect(reified.findings).toMatchObject([ + { + validatorId: extractFindingIds.reservedProperty, + severity: "error", + path: "hasVerifier", + }, + ]); + }); + + it("retains a nested __proto__ model term as own string content", () => { + const reified = reifyTypeScriptCarrier( + `import { spec, specId } from "@libar-dev/software-delivery-protocol"; +export const carrier = spec({ + id: specId("spec:orders.proto-string-term"), + kind: "model", + altitude: "story", + readiness: "idea", + model: { terms: { "__proto__": "A string-authored domain term." } }, +});`, + "proto-string-term.sdp.ts", + ); + + const graph = deriveGraph(reified.specs, reified.packs, []); + const terms = primitiveNode(graph, "spec:orders.proto-string-term")?.sections?.model?.terms; + + expect(reified.findings).toEqual([]); + expect(terms).toBeDefined(); + if (terms === undefined) throw new Error("expected Model terms to reify as an object"); + expect(Object.hasOwn(terms, "__proto__")).toBe(true); + expect(Object.getOwnPropertyDescriptor(terms, "__proto__")?.value).toBe( + "A string-authored domain term.", + ); + }); + + it("retains an object-valued nested __proto__ term without prototype pollution", () => { + const reified = reifyTypeScriptCarrier( + `import { spec, specId } from "@libar-dev/software-delivery-protocol"; +export const carrier = spec({ + id: specId("spec:orders.proto-object-term"), + kind: "model", + altitude: "story", + readiness: "idea", + model: { terms: { "__proto__": { detail: "An object-authored domain term." } } }, +});`, + "proto-object-term.sdp.ts", + ); + + const graph = deriveGraph(reified.specs, reified.packs, []); + const terms = primitiveNode(graph, "spec:orders.proto-object-term")?.sections?.model?.terms; + + expect(reified.findings).toEqual([]); + expect(terms).toBeDefined(); + if (terms === undefined) throw new Error("expected Model terms to reify as an object"); + expect(Object.getPrototypeOf(terms)).toBe(Object.prototype); + expect(Object.keys(terms)).toContain("__proto__"); + }); + + it("retains a Markdown __proto__ Model term through extract and serializeGraph", () => { + const root = temporaryCorpusRoot("markdown-proto-term-serialization"); + writeFileSync( + join(root, "specs", "proto-term.sdp.md"), + `--- +id: spec:orders.proto-term-serialization +kind: model +altitude: story +readiness: idea +relations: {} +--- +# Proto term serialization + +## Model + +- **__proto__** — Something authored. +`, + "utf8", + ); + + const result = extract({ root }); + + expect(result.report.findings).toEqual([]); + expect(serializeGraph(result.graph)).toContain('"__proto__": "Something authored."'); + }); + it("id-shaped-string-content: a raw id-shaped string in section content is prose — kept, edge-free, finding-free", () => { const result = extract({ root: corpusRoot("id-shaped-string-content") }); @@ -575,9 +672,30 @@ describe("Markdown carrier discovery", () => { id: "spec:carrier.sdp-import", file: "specs/carrier/sdp-import.sdp.md", specKind: "behavior", - title: "Existing intent can later be imported into the ruled carrier", + title: "TypeScript-carried Specs can become Markdown twins", sections: { - intent: { outcome: "Name import as deferred work without claiming an emitter exists." }, + intent: { + actor: "A coding agent or maintainer.", + outcome: + "Convert a TypeScript-carrier Spec into an idiomatic `.sdp.md` twin beside its source.", + value: + "The TypeScript DSL survives as an import source while Markdown becomes the authored twin.", + }, + behavior: { + rules: [ + "Import writes the emitted Markdown sibling beside the TypeScript carrier and never deletes the source carrier.", + "Import refuses an existing Markdown sibling rather than overwriting it.", + "Refusal outcomes retain the TypeScript reifier findings and add import-local findings honestly.", + "Import consumes the TypeScript reifier so source acceptance follows one validation path.", + "A batch scans only bounded source directories, canonicalizes physical carrier identity, and computes every refusal and target collision before publishing any sibling.", + "Publication prepares exclusive temporary siblings and atomically creates targets without clobbering; rollback attempts every artifact, reports survivors, and never deletes a TypeScript source.", + ], + exampleSpace: { + given: ["a TS-carrier spec"], + when: ["importTypeScriptSpec runs"], + [["t", "hen"].join("")]: ["the emitted Markdown re-parses to an equal graph"], + }, + }, }, }, ]); @@ -979,6 +1097,7 @@ describe("import-surface and discovery corpora", () => { "./explorations", "explorations/", "/explorations", + "C:/work/specs", "../x", "a/../b", "a//b", @@ -989,7 +1108,7 @@ describe("import-surface and discovery corpora", () => { // When / Then: malformed consumer scope is refused rather than broadened or normalized. expect(() => extract({ root, exclude: [exclude] })).toThrow( - `invalid --exclude path "${exclude}"`, + `normalizeExcludes: invalid exclusion path "${exclude}"`, ); }); }); diff --git a/test/fixtures/checkout-v1/expected-design-review/index.md b/test/fixtures/checkout-v1/expected-design-review/index.md index 79e81dd..2453247 100644 --- a/test/fixtures/checkout-v1/expected-design-review/index.md +++ b/test/fixtures/checkout-v1/expected-design-review/index.md @@ -26,7 +26,7 @@ The one generated read-only view — a pure projection of the one graph (`graph. | Severity | Check | Message | Where | |---|---|---|---| -| warning | `conformance/verifies-linkage` | Example "spec:orders.create-order.invalid-cart" declares verifies → "spec:orders.create-order" but is not an enabled verifier — no test anchor binds it, so the spec↔test trace is incomplete and it confers no has-verifier. | `specs/orders/create-order-invalid-cart.sdp.ts` | +| warning | `conformance/verifies-linkage` | Example "spec:orders.create-order.invalid-cart" declares verifies → "spec:orders.create-order" but is not an enabled verifier — no test anchor binds it, so the spec↔test trace is incomplete and it confers no has-verifier. | `specs/orders/create-order-invalid-cart.sdp.md` | --- diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/decisions.order-lifecycle.md b/test/fixtures/checkout-v1/expected-design-review/spec/decisions.order-lifecycle.md index afb6929..2c0cfb2 100644 --- a/test/fixtures/checkout-v1/expected-design-review/spec/decisions.order-lifecycle.md +++ b/test/fixtures/checkout-v1/expected-design-review/spec/decisions.order-lifecycle.md @@ -1,6 +1,6 @@ # Order lifecycle keeps validation before creation -`spec:decisions.order-lifecycle` · Decision Record (`decision`) · altitude `feature` · authored in [specs/decisions/order-lifecycle.sdp.ts](../../../specs/decisions/order-lifecycle.sdp.ts) `[declared]` +`spec:decisions.order-lifecycle` · Decision Record (`decision`) · altitude `feature` · authored in [specs/decisions/order-lifecycle.sdp.md](../../../specs/decisions/order-lifecycle.sdp.md) `[declared]` **Readiness:** stated `defined` · structural floor reached: `ready` diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.api-contract.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.api-contract.md index 844853b..5fa66a4 100644 --- a/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.api-contract.md +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.api-contract.md @@ -1,6 +1,6 @@ # Create-order API contract -`spec:orders.create-order.api-contract` · Contract (`contract`) · altitude `story` · authored in [specs/orders/create-order-api-contract.sdp.ts](../../../specs/orders/create-order-api-contract.sdp.ts) `[declared]` +`spec:orders.create-order.api-contract` · Contract (`contract`) · altitude `story` · authored in [specs/orders/create-order-api-contract.sdp.md](../../../specs/orders/create-order-api-contract.sdp.md) `[declared]` **Readiness:** stated `idea` · structural floor reached: `idea` diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.invalid-cart.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.invalid-cart.md index bf3bb42..785f68b 100644 --- a/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.invalid-cart.md +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.invalid-cart.md @@ -1,6 +1,6 @@ # Invalid cart is rejected -`spec:orders.create-order.invalid-cart` · Example / Scenario (`example`) · altitude `story` · authored in [specs/orders/create-order-invalid-cart.sdp.ts](../../../specs/orders/create-order-invalid-cart.sdp.ts) `[declared]` +`spec:orders.create-order.invalid-cart` · Example / Scenario (`example`) · altitude `story` · authored in [specs/orders/create-order-invalid-cart.sdp.md](../../../specs/orders/create-order-invalid-cart.sdp.md) `[declared]` **Readiness:** stated `defined` · structural floor reached: `ready` @@ -49,7 +49,7 @@ Every line is a one-hop neighbor over the curated graph: changing this spec touc | Severity | Check | Message | Where | |---|---|---|---| -| warning | `conformance/verifies-linkage` | Example "spec:orders.create-order.invalid-cart" declares verifies → "spec:orders.create-order" but is not an enabled verifier — no test anchor binds it, so the spec↔test trace is incomplete and it confers no has-verifier. | `specs/orders/create-order-invalid-cart.sdp.ts` | +| warning | `conformance/verifies-linkage` | Example "spec:orders.create-order.invalid-cart" declares verifies → "spec:orders.create-order" but is not an enabled verifier — no test anchor binds it, so the spec↔test trace is incomplete and it confers no has-verifier. | `specs/orders/create-order-invalid-cart.sdp.md` | --- diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.md index f3d4c2a..4d17002 100644 --- a/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.md +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.md @@ -1,6 +1,6 @@ # Customer creates an order -`spec:orders.create-order` · Use Case / Behavior (`behavior`) · altitude `feature` · authored in [specs/orders/create-order.sdp.ts](../../../specs/orders/create-order.sdp.ts) `[declared]` +`spec:orders.create-order` · Use Case / Behavior (`behavior`) · altitude `feature` · authored in [specs/orders/create-order.sdp.md](../../../specs/orders/create-order.sdp.md) `[declared]` **Readiness:** stated `defined` · structural floor reached: `ready` @@ -18,8 +18,8 @@ ### Verifiers -- `spec:orders.create-order.invalid-cart` — Invalid cart is rejected ([specs/orders/create-order-invalid-cart.sdp.ts](../../../specs/orders/create-order-invalid-cart.sdp.ts)) — **not enabled** (no test anchor binds this example — it confers no verifier binding) `[declared]` -- `spec:orders.create-order.valid-cart` — Valid cart creates an order ([specs/orders/create-order-valid-cart.sdp.ts](../../../specs/orders/create-order-valid-cart.sdp.ts)) — **enabled** (a resolving test anchor binds this example) `[declared]` +- `spec:orders.create-order.invalid-cart` — Invalid cart is rejected ([specs/orders/create-order-invalid-cart.sdp.md](../../../specs/orders/create-order-invalid-cart.sdp.md)) — **not enabled** (no test anchor binds this example — it confers no verifier binding) `[declared]` +- `spec:orders.create-order.valid-cart` — Valid cart creates an order ([specs/orders/create-order-valid-cart.sdp.md](../../../specs/orders/create-order-valid-cart.sdp.md)) — **enabled** (a resolving test anchor binds this example) `[declared]` ### Expected-outcome oracle @@ -39,13 +39,13 @@ - **given** - a customer has a cart with {n:number} line items - every line item has quantity {q:number} and unit price {price:number} - - every cart item is {availability:"in stock"|"out of stock"} + - every cart item is {availability:"in stock"\|"out of stock"} - **when** - the customer submits the cart for order creation - **then** - an order is created with total {total:number} - the order contains the original cart lines - - order creation is rejected because {reason:"empty cart"|"out of stock"} + - order creation is rejected because {reason:"empty cart"\|"out of stock"} ## Relations & impact (one hop) @@ -68,7 +68,7 @@ Every line is a one-hop neighbor over the curated graph: changing this spec touc | Severity | Check | Message | Where | |---|---|---|---| -| warning | `conformance/verifies-linkage` | Example "spec:orders.create-order.invalid-cart" declares verifies → "spec:orders.create-order" but is not an enabled verifier — no test anchor binds it, so the spec↔test trace is incomplete and it confers no has-verifier. | `specs/orders/create-order-invalid-cart.sdp.ts` | +| warning | `conformance/verifies-linkage` | Example "spec:orders.create-order.invalid-cart" declares verifies → "spec:orders.create-order" but is not an enabled verifier — no test anchor binds it, so the spec↔test trace is incomplete and it confers no has-verifier. | `specs/orders/create-order-invalid-cart.sdp.md` | --- diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.valid-cart.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.valid-cart.md index 24c1d7f..bf95f78 100644 --- a/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.valid-cart.md +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.valid-cart.md @@ -1,6 +1,6 @@ # Valid cart creates an order -`spec:orders.create-order.valid-cart` · Example / Scenario (`example`) · altitude `story` · authored in [specs/orders/create-order-valid-cart.sdp.ts](../../../specs/orders/create-order-valid-cart.sdp.ts) `[declared]` +`spec:orders.create-order.valid-cart` · Example / Scenario (`example`) · altitude `story` · authored in [specs/orders/create-order-valid-cart.sdp.md](../../../specs/orders/create-order-valid-cart.sdp.md) `[declared]` **Readiness:** stated `ready` · structural floor reached: `ready` diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-inventory-rule.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-inventory-rule.md index d7a84d3..ca4a6a5 100644 --- a/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-inventory-rule.md +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-inventory-rule.md @@ -1,6 +1,6 @@ # Order creation requires available inventory -`spec:orders.order-inventory-rule` · Business Rule (`rule`) · altitude `story` · authored in [specs/orders/order-inventory-rule.sdp.ts](../../../specs/orders/order-inventory-rule.sdp.ts) `[declared]` +`spec:orders.order-inventory-rule` · Business Rule (`rule`) · altitude `story` · authored in [specs/orders/order-inventory-rule.sdp.md](../../../specs/orders/order-inventory-rule.sdp.md) `[declared]` **Readiness:** stated `defined` · structural floor reached: `ready` diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-latency-constraint.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-latency-constraint.md index 73d6857..a23f67d 100644 --- a/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-latency-constraint.md +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-latency-constraint.md @@ -1,6 +1,6 @@ # Create-order latency stays within checkout budget -`spec:orders.order-latency-constraint` · Constraint (NFR) (`constraint`) · altitude `story` · authored in [specs/orders/order-latency-constraint.sdp.ts](../../../specs/orders/order-latency-constraint.sdp.ts) `[declared]` +`spec:orders.order-latency-constraint` · Constraint (NFR) (`constraint`) · altitude `story` · authored in [specs/orders/order-latency-constraint.sdp.md](../../../specs/orders/order-latency-constraint.sdp.md) `[declared]` **Readiness:** stated `defined` · structural floor reached: `ready` diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-management.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-management.md index 151554f..b0ccb93 100644 --- a/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-management.md +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-management.md @@ -1,6 +1,6 @@ # Order management -`spec:orders.order-management` · Use Case / Behavior (`behavior`) · altitude `epic` · authored in [specs/orders/order-management.sdp.ts](../../../specs/orders/order-management.sdp.ts) `[declared]` +`spec:orders.order-management` · Use Case / Behavior (`behavior`) · altitude `epic` · authored in [specs/orders/order-management.sdp.md](../../../specs/orders/order-management.sdp.md) `[declared]` **Readiness:** stated `defined` · structural floor reached: `ready` diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-model.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-model.md index 7c8f223..663a10e 100644 --- a/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-model.md +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-model.md @@ -1,6 +1,6 @@ # Order-management domain vocabulary -`spec:orders.order-model` · Domain Model (`model`) · altitude `story` · authored in [specs/orders/order-model.sdp.ts](../../../specs/orders/order-model.sdp.ts) `[declared]` +`spec:orders.order-model` · Domain Model (`model`) · altitude `story` · authored in [specs/orders/order-model.sdp.md](../../../specs/orders/order-model.sdp.md) `[declared]` **Readiness:** stated `defined` · structural floor reached: `ready` diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-placement-flow.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-placement-flow.md index a9c459b..b7a0627 100644 --- a/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-placement-flow.md +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-placement-flow.md @@ -1,6 +1,6 @@ # Order placement flow -`spec:orders.order-placement-flow` · Workflow (`workflow`) · altitude `feature` · authored in [specs/orders/order-placement-flow.sdp.ts](../../../specs/orders/order-placement-flow.sdp.ts) `[declared]` +`spec:orders.order-placement-flow` · Workflow (`workflow`) · altitude `feature` · authored in [specs/orders/order-placement-flow.sdp.md](../../../specs/orders/order-placement-flow.sdp.md) `[declared]` **Readiness:** stated `scoped` · structural floor reached: `scoped` diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-total-rule.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-total-rule.md index e61133e..b97d84c 100644 --- a/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-total-rule.md +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-total-rule.md @@ -1,6 +1,6 @@ # Order total matches cart math -`spec:orders.order-total-rule` · Business Rule (`rule`) · altitude `story` · authored in [specs/orders/order-total-rule.sdp.ts](../../../specs/orders/order-total-rule.sdp.ts) `[declared]` +`spec:orders.order-total-rule` · Business Rule (`rule`) · altitude `story` · authored in [specs/orders/order-total-rule.sdp.md](../../../specs/orders/order-total-rule.sdp.md) `[declared]` **Readiness:** stated `defined` · structural floor reached: `ready` diff --git a/test/fixtures/checkout-v1/expected-graph.json b/test/fixtures/checkout-v1/expected-graph.json index 63ed3fd..784df18 100644 --- a/test/fixtures/checkout-v1/expected-graph.json +++ b/test/fixtures/checkout-v1/expected-graph.json @@ -52,7 +52,7 @@ "altitude": "feature", "readiness": "defined", "title": "Order lifecycle keeps validation before creation", - "file": "specs/decisions/order-lifecycle.sdp.ts", + "file": "specs/decisions/order-lifecycle.sdp.md", "sections": { "intent": { "outcome": "Decide when checkout-v1 may create an order.", @@ -78,7 +78,7 @@ "altitude": "feature", "readiness": "defined", "title": "Customer creates an order", - "file": "specs/orders/create-order.sdp.ts", + "file": "specs/orders/create-order.sdp.md", "sections": { "intent": { "actor": "customer", @@ -116,7 +116,7 @@ "altitude": "story", "readiness": "idea", "title": "Create-order API contract", - "file": "specs/orders/create-order-api-contract.sdp.ts", + "file": "specs/orders/create-order-api-contract.sdp.md", "sections": { "intent": { "outcome": "Pin the request and response shapes of POST /orders.", @@ -137,7 +137,7 @@ "altitude": "story", "readiness": "defined", "title": "Invalid cart is rejected", - "file": "specs/orders/create-order-invalid-cart.sdp.ts", + "file": "specs/orders/create-order-invalid-cart.sdp.md", "sections": { "intent": { "outcome": "Show that an invalid cart does not become an order.", @@ -175,7 +175,7 @@ "altitude": "story", "readiness": "ready", "title": "Valid cart creates an order", - "file": "specs/orders/create-order-valid-cart.sdp.ts", + "file": "specs/orders/create-order-valid-cart.sdp.md", "sections": { "intent": { "outcome": "Show that a valid cart can become an order.", @@ -219,7 +219,7 @@ "altitude": "story", "readiness": "defined", "title": "Order creation requires available inventory", - "file": "specs/orders/order-inventory-rule.sdp.ts", + "file": "specs/orders/order-inventory-rule.sdp.md", "sections": { "intent": { "outcome": "Reject carts whose items are not fully available.", @@ -241,7 +241,7 @@ "altitude": "story", "readiness": "defined", "title": "Create-order latency stays within checkout budget", - "file": "specs/orders/order-latency-constraint.sdp.ts", + "file": "specs/orders/order-latency-constraint.sdp.md", "sections": { "intent": { "outcome": "Keep create-order fast enough for interactive checkout.", @@ -264,7 +264,7 @@ "altitude": "epic", "readiness": "defined", "title": "Order management", - "file": "specs/orders/order-management.sdp.ts", + "file": "specs/orders/order-management.sdp.md", "sections": { "intent": { "outcome": "Coordinate the authored order-management slice for checkout v1.", @@ -286,7 +286,7 @@ "altitude": "story", "readiness": "defined", "title": "Order-management domain vocabulary", - "file": "specs/orders/order-model.sdp.ts", + "file": "specs/orders/order-model.sdp.md", "sections": { "intent": { "outcome": "Define the core terms used by the checkout-v1 order-management slice.", @@ -311,7 +311,7 @@ "altitude": "feature", "readiness": "scoped", "title": "Order placement flow", - "file": "specs/orders/order-placement-flow.sdp.ts", + "file": "specs/orders/order-placement-flow.sdp.md", "sections": { "intent": { "outcome": "Sequence cart submission through validation to a created order.", @@ -334,7 +334,7 @@ "altitude": "story", "readiness": "defined", "title": "Order total matches cart math", - "file": "specs/orders/order-total-rule.sdp.ts", + "file": "specs/orders/order-total-rule.sdp.md", "sections": { "intent": { "outcome": "Keep the order total equal to the sum of cart line subtotals.", diff --git a/test/fixtures/extract/self-hosting-carrier/specs/carrier/envelope-contract.sdp.md.txt b/test/fixtures/extract/self-hosting-carrier/specs/carrier/envelope-contract.sdp.md.txt index 1fd03f2..972f3b6 100644 --- a/test/fixtures/extract/self-hosting-carrier/specs/carrier/envelope-contract.sdp.md.txt +++ b/test/fixtures/extract/self-hosting-carrier/specs/carrier/envelope-contract.sdp.md.txt @@ -5,6 +5,7 @@ altitude: feature readiness: defined relations: refines: spec:carrier.markdown-authoring + decidedBy: spec:decisions.envelope-grammar-posture --- # The Markdown envelope is explicit and bounded diff --git a/test/fixtures/extract/self-hosting-carrier/specs/carrier/markdown-authoring.sdp.md.txt b/test/fixtures/extract/self-hosting-carrier/specs/carrier/markdown-authoring.sdp.md.txt index f8d5f55..038bb4a 100644 --- a/test/fixtures/extract/self-hosting-carrier/specs/carrier/markdown-authoring.sdp.md.txt +++ b/test/fixtures/extract/self-hosting-carrier/specs/carrier/markdown-authoring.sdp.md.txt @@ -5,6 +5,9 @@ altitude: feature readiness: defined relations: dependsOn: spec:carrier.markdown-parser + decidedBy: + - spec:decisions.sdp-ts-extension + - spec:decisions.carrier-ruling --- # Markdown authoring enters the one graph diff --git a/test/fixtures/extract/self-hosting-carrier/specs/carrier/prose-ownership-rule.sdp.md.txt b/test/fixtures/extract/self-hosting-carrier/specs/carrier/prose-ownership-rule.sdp.md.txt index 662c088..9ccc07c 100644 --- a/test/fixtures/extract/self-hosting-carrier/specs/carrier/prose-ownership-rule.sdp.md.txt +++ b/test/fixtures/extract/self-hosting-carrier/specs/carrier/prose-ownership-rule.sdp.md.txt @@ -5,6 +5,7 @@ altitude: story readiness: defined relations: refines: spec:carrier.markdown-authoring + decidedBy: spec:decisions.prose-ownership --- # Every prose edge has one owner diff --git a/test/fixtures/extract/self-hosting-carrier/specs/carrier/sdp-import.sdp.md.txt b/test/fixtures/extract/self-hosting-carrier/specs/carrier/sdp-import.sdp.md.txt index f14f496..6b40644 100644 --- a/test/fixtures/extract/self-hosting-carrier/specs/carrier/sdp-import.sdp.md.txt +++ b/test/fixtures/extract/self-hosting-carrier/specs/carrier/sdp-import.sdp.md.txt @@ -2,11 +2,28 @@ id: spec:carrier.sdp-import kind: behavior altitude: feature -readiness: idea +readiness: ready relations: refines: spec:carrier.markdown-authoring --- -# Existing intent can later be imported into the ruled carrier +# TypeScript-carried Specs can become Markdown twins ## Intent -- outcome: Name import as deferred work without claiming an emitter exists. +- actor: A coding agent or maintainer. +- outcome: Convert a TypeScript-carrier Spec into an idiomatic `.sdp.md` twin beside its source. +- value: The TypeScript DSL survives as an import source while Markdown becomes the authored twin. + +## Behavior +- rule: Import writes the emitted Markdown sibling beside the TypeScript carrier and never deletes the source carrier. +- rule: Import refuses an existing Markdown sibling rather than overwriting it. +- rule: Refusal outcomes retain the TypeScript reifier findings and add import-local findings honestly. +- rule: Import consumes the TypeScript reifier so source acceptance follows one validation path. +- rule: A batch scans only bounded source directories, canonicalizes physical carrier identity, and computes every refusal and target collision before publishing any sibling. +- rule: Publication prepares exclusive temporary siblings and atomically creates targets without clobbering; rollback attempts every artifact, reports survivors, and never deletes a TypeScript source. + +## Example space +```gwt-vocabulary +Given a TS-carrier spec +When importTypeScriptSpec runs +Then the emitted Markdown re-parses to an equal graph +``` diff --git a/test/fixtures/import/parity/duplicate-id.sdp.md b/test/fixtures/import/parity/duplicate-id.sdp.md new file mode 100644 index 0000000..ed25b56 --- /dev/null +++ b/test/fixtures/import/parity/duplicate-id.sdp.md @@ -0,0 +1,8 @@ +--- +id: spec:parity.duplicate-id +kind: behavior +altitude: story +readiness: idea +relations: {} +--- +# Duplicate id probe diff --git a/test/fixtures/import/parity/duplicate-id.sdp.ts b/test/fixtures/import/parity/duplicate-id.sdp.ts new file mode 100644 index 0000000..4773b8a --- /dev/null +++ b/test/fixtures/import/parity/duplicate-id.sdp.ts @@ -0,0 +1,8 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const duplicateIdProbe = spec({ + id: specId("spec:parity.duplicate-id"), + kind: "behavior", + altitude: "story", + readiness: "idea", +}); diff --git a/test/fixtures/import/parity/invalid-id.sdp.md b/test/fixtures/import/parity/invalid-id.sdp.md new file mode 100644 index 0000000..fe951af --- /dev/null +++ b/test/fixtures/import/parity/invalid-id.sdp.md @@ -0,0 +1,8 @@ +--- +id: not-a-protocol-id +kind: behavior +altitude: story +readiness: idea +relations: {} +--- +# Invalid id probe diff --git a/test/fixtures/import/parity/invalid-id.sdp.ts b/test/fixtures/import/parity/invalid-id.sdp.ts new file mode 100644 index 0000000..aa80858 --- /dev/null +++ b/test/fixtures/import/parity/invalid-id.sdp.ts @@ -0,0 +1,8 @@ +import { spec } from "@libar-dev/software-delivery-protocol"; + +export const invalidIdProbe = spec({ + id: "not-a-protocol-id", + kind: "behavior", + altitude: "story", + readiness: "idea", +}); diff --git a/test/fixtures/import/parity/misplaced-authoring.sdp.md b/test/fixtures/import/parity/misplaced-authoring.sdp.md new file mode 100644 index 0000000..accf8ed --- /dev/null +++ b/test/fixtures/import/parity/misplaced-authoring.sdp.md @@ -0,0 +1,15 @@ +--- +id: spec:parity.misplaced-authoring +kind: example +altitude: story +readiness: idea +relations: {} +--- +# Misplaced authoring probe +## Intent +```gwt +Given a valid input +When it is processed +Then an output exists +``` +- outcome: This late field violates immediate GWT placement. diff --git a/test/fixtures/import/parity/misplaced-authoring.sdp.ts b/test/fixtures/import/parity/misplaced-authoring.sdp.ts new file mode 100644 index 0000000..0819b9d --- /dev/null +++ b/test/fixtures/import/parity/misplaced-authoring.sdp.ts @@ -0,0 +1,8 @@ +import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; + +export function misplacedAuthoringProbe() { + return codeAnchor({ + id: codeAnchorId("impl:parity.misplaced-authoring"), + satisfies: ref("spec:parity.misplaced-authoring"), + }); +} diff --git a/test/fixtures/import/parity/non-static-envelope.sdp.md b/test/fixtures/import/parity/non-static-envelope.sdp.md new file mode 100644 index 0000000..3da1031 --- /dev/null +++ b/test/fixtures/import/parity/non-static-envelope.sdp.md @@ -0,0 +1,8 @@ +--- +id: spec:parity.non-static-envelope +kind: dynamic +altitude: story +readiness: idea +relations: {} +--- +# Non-static envelope probe diff --git a/test/fixtures/import/parity/non-static-envelope.sdp.ts b/test/fixtures/import/parity/non-static-envelope.sdp.ts new file mode 100644 index 0000000..25e8b38 --- /dev/null +++ b/test/fixtures/import/parity/non-static-envelope.sdp.ts @@ -0,0 +1,10 @@ +import { spec } from "@libar-dev/software-delivery-protocol"; + +const id = "spec:parity.non-static-envelope"; + +export const nonStaticEnvelopeProbe = spec({ + id, + kind: "behavior", + altitude: "story", + readiness: "idea", +}); diff --git a/test/fixtures/import/parity/non-static-section.sdp.md b/test/fixtures/import/parity/non-static-section.sdp.md new file mode 100644 index 0000000..d543ba2 --- /dev/null +++ b/test/fixtures/import/parity/non-static-section.sdp.md @@ -0,0 +1,11 @@ +--- +id: spec:parity.non-static-section +kind: behavior +altitude: story +readiness: idea +relations: {} +--- +# Non-static section probe +## Rule +- A known rule. + Nested structure is not accepted. diff --git a/test/fixtures/import/parity/non-static-section.sdp.ts b/test/fixtures/import/parity/non-static-section.sdp.ts new file mode 100644 index 0000000..68766bd --- /dev/null +++ b/test/fixtures/import/parity/non-static-section.sdp.ts @@ -0,0 +1,9 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const nonStaticSectionProbe = spec({ + id: specId("spec:parity.non-static-section"), + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: ["computed", "at runtime"].join(" ") }, +}); diff --git a/test/fixtures/import/parity/parse-error.sdp.md b/test/fixtures/import/parity/parse-error.sdp.md new file mode 100644 index 0000000..df12368 --- /dev/null +++ b/test/fixtures/import/parity/parse-error.sdp.md @@ -0,0 +1,8 @@ +--- +id: [spec:parity.parse-error +kind: behavior +altitude: story +readiness: idea +relations: {} +--- +# Parse error probe diff --git a/test/fixtures/import/parity/parse-error.sdp.ts b/test/fixtures/import/parity/parse-error.sdp.ts new file mode 100644 index 0000000..104a940 --- /dev/null +++ b/test/fixtures/import/parity/parse-error.sdp.ts @@ -0,0 +1,9 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const parseErrorProbe = spec({ + id: specId("spec:parity.parse-error"), + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: `unterminated +}); diff --git a/test/fixtures/import/parity/reserved-property.sdp.md b/test/fixtures/import/parity/reserved-property.sdp.md new file mode 100644 index 0000000..46295c3 --- /dev/null +++ b/test/fixtures/import/parity/reserved-property.sdp.md @@ -0,0 +1,9 @@ +--- +id: spec:parity.reserved-property +kind: behavior +altitude: story +readiness: idea +relations: {} +deliveryFacts: implemented +--- +# Reserved property probe diff --git a/test/fixtures/import/parity/reserved-property.sdp.ts b/test/fixtures/import/parity/reserved-property.sdp.ts new file mode 100644 index 0000000..ce5b0bd --- /dev/null +++ b/test/fixtures/import/parity/reserved-property.sdp.ts @@ -0,0 +1,9 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const reservedPropertyProbe = spec({ + id: specId("spec:parity.reserved-property"), + kind: "behavior", + altitude: "story", + readiness: "idea", + deliveryFacts: ["implemented"], +}); diff --git a/test/fixtures/import/parity/unowned-prose.sdp.md b/test/fixtures/import/parity/unowned-prose.sdp.md new file mode 100644 index 0000000..7226c5d --- /dev/null +++ b/test/fixtures/import/parity/unowned-prose.sdp.md @@ -0,0 +1,11 @@ +--- +id: spec:parity.unowned-prose +kind: behavior +altitude: story +readiness: idea +relations: {} +--- +# Unowned prose probe +## Rule +- A known rule. +This prose has no owner after structured content. diff --git a/test/fixtures/import/parity/unowned-prose.sdp.ts b/test/fixtures/import/parity/unowned-prose.sdp.ts new file mode 100644 index 0000000..36bba01 --- /dev/null +++ b/test/fixtures/import/parity/unowned-prose.sdp.ts @@ -0,0 +1,9 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const unownedProseProbe = spec({ + id: specId("spec:parity.unowned-prose"), + kind: "behavior", + altitude: "story", + readiness: "idea", + description: "This prose has no owning section.", +}); diff --git a/test/fixtures/import/parity/unrecognized-property.sdp.md b/test/fixtures/import/parity/unrecognized-property.sdp.md new file mode 100644 index 0000000..19f3572 --- /dev/null +++ b/test/fixtures/import/parity/unrecognized-property.sdp.md @@ -0,0 +1,9 @@ +--- +id: spec:parity.unrecognized-property +kind: behavior +altitude: story +readiness: idea +relations: {} +behaviour: typoed envelope field +--- +# Unrecognized property probe diff --git a/test/fixtures/import/parity/unrecognized-property.sdp.ts b/test/fixtures/import/parity/unrecognized-property.sdp.ts new file mode 100644 index 0000000..4bc4b48 --- /dev/null +++ b/test/fixtures/import/parity/unrecognized-property.sdp.ts @@ -0,0 +1,9 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const unrecognizedPropertyProbe = spec({ + id: specId("spec:parity.unrecognized-property"), + kind: "behavior", + altitude: "story", + readiness: "idea", + behaviour: { rules: ["This typoed section is dropped loudly."] }, +}); diff --git a/test/fixtures/import/parity/unrecognized-statement.sdp.md b/test/fixtures/import/parity/unrecognized-statement.sdp.md new file mode 100644 index 0000000..a27ffdf --- /dev/null +++ b/test/fixtures/import/parity/unrecognized-statement.sdp.md @@ -0,0 +1,11 @@ +--- +id: spec:parity.unrecognized-statement +kind: behavior +altitude: story +readiness: idea +relations: {} +--- +# Unrecognized statement probe +## Rule +- A known rule. + diff --git a/test/fixtures/import/parity/unrecognized-statement.sdp.ts b/test/fixtures/import/parity/unrecognized-statement.sdp.ts new file mode 100644 index 0000000..2528d99 --- /dev/null +++ b/test/fixtures/import/parity/unrecognized-statement.sdp.ts @@ -0,0 +1,12 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const recognizedProbe = spec({ + id: specId("spec:parity.unrecognized-statement"), + kind: "behavior", + altitude: "story", + readiness: "idea", +}); + +export function foreignStatement(): string { + return "outside the authored grammar"; +} diff --git a/test/fixtures/import/round-trip/anchors.ts.txt b/test/fixtures/import/round-trip/anchors.ts.txt new file mode 100644 index 0000000..4eb458d --- /dev/null +++ b/test/fixtures/import/round-trip/anchors.ts.txt @@ -0,0 +1,19 @@ +import { + codeAnchor, + codeAnchorId, + specId, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; + +export const behaviorRoundTripAnchor = codeAnchor({ + id: codeAnchorId("impl:round-trip.behavior"), + label: "implements the round-trip behavior fixture", + satisfies: specId("spec:round-trip.behavior"), +}); + +export const exampleRoundTripVerifier = specTest({ + id: testAnchorId("test:round-trip.example"), + label: "verifies the round-trip example fixture", + verifies: specId("spec:round-trip.example"), +}); diff --git a/test/fixtures/import/round-trip/behavior.sdp.ts.txt b/test/fixtures/import/round-trip/behavior.sdp.ts.txt new file mode 100644 index 0000000..fb7f581 --- /dev/null +++ b/test/fixtures/import/round-trip/behavior.sdp.ts.txt @@ -0,0 +1,21 @@ +import { constrainedBy, refines, spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const behaviorRoundTripSpec = spec({ + id: specId("spec:round-trip.behavior"), + title: "Accept an order under its rules", + kind: "behavior", + altitude: "feature", + readiness: "defined", + relations: [ + refines(specId("spec:round-trip.decision")), + constrainedBy(specId("spec:round-trip.constraint")), + ], + intent: { + description: "An author needs the behavior's governing rules to survive import.", + outcome: "The emitted carrier preserves the authored model.", + }, + behavior: { + description: "The behavior decides whether an order is eligible.", + rules: ["An order is accepted only when its total is positive.", "A cancelled order is refused."], + }, +}); diff --git a/test/fixtures/import/round-trip/constraint.sdp.ts.txt b/test/fixtures/import/round-trip/constraint.sdp.ts.txt new file mode 100644 index 0000000..f71e1bb --- /dev/null +++ b/test/fixtures/import/round-trip/constraint.sdp.ts.txt @@ -0,0 +1,21 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const constraintRoundTripSpec = spec({ + id: specId("spec:round-trip.constraint"), + title: "Keep order acceptance deterministic", + kind: "constraint", + altitude: "story", + readiness: "defined", + relations: [], + intent: { + description: "An acceptance decision must be repeatable.", + }, + constraints: [ + { + statement: "The same order data produces the same acceptance result.", + flavor: "quality", + target: "order acceptance", + measurableBy: "a deterministic verifier", + }, + ], +}); diff --git a/test/fixtures/import/round-trip/contract.sdp.ts.txt b/test/fixtures/import/round-trip/contract.sdp.ts.txt new file mode 100644 index 0000000..9b379c7 --- /dev/null +++ b/test/fixtures/import/round-trip/contract.sdp.ts.txt @@ -0,0 +1,11 @@ +import { refines, spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const contractRoundTripSpec = spec({ + id: specId("spec:round-trip.contract"), + title: "Import a contract", + kind: "contract", + altitude: "story", + readiness: "idea", + relations: [refines(specId("spec:round-trip.behavior"))], + behavior: { rules: ["The emitted document reifies to the same authored data."] }, +}); diff --git a/test/fixtures/import/round-trip/decision.sdp.ts.txt b/test/fixtures/import/round-trip/decision.sdp.ts.txt new file mode 100644 index 0000000..a8b1882 --- /dev/null +++ b/test/fixtures/import/round-trip/decision.sdp.ts.txt @@ -0,0 +1,19 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const decisionRoundTripSpec = spec({ + id: specId("spec:round-trip.decision"), + title: "Keep eligibility rules explicit", + kind: "decision", + altitude: "feature", + readiness: "defined", + relations: [], + intent: { + description: "A behavior needs a governing delivery decision.", + }, + decision: { + context: "Eligibility must be reviewable without running code.", + decision: "Represent eligibility as authored behavior rules.", + rationale: ["The rules remain visible in every carrier."], + consequences: ["The behavior can state a precise relation to its decision."], + }, +}); diff --git a/test/fixtures/import/round-trip/example.sdp.ts.txt b/test/fixtures/import/round-trip/example.sdp.ts.txt new file mode 100644 index 0000000..691260a --- /dev/null +++ b/test/fixtures/import/round-trip/example.sdp.ts.txt @@ -0,0 +1,22 @@ +import { spec, specId, verifies } from "@libar-dev/software-delivery-protocol"; + +export const exampleRoundTripSpec = spec({ + id: specId("spec:round-trip.example"), + title: "Accept a positive order", + kind: "example", + altitude: "story", + readiness: "ready", + relations: [verifies(specId("spec:round-trip.behavior"))], + intent: { + description: "A verifier needs one executable order scenario.", + }, + behavior: { + examples: [ + { + given: ["an order has a positive total"], + when: ["the order is submitted"], + "th\u0065n": ["the order is accepted"], + }, + ], + }, +}); diff --git a/test/fixtures/import/round-trip/model.sdp.ts.txt b/test/fixtures/import/round-trip/model.sdp.ts.txt new file mode 100644 index 0000000..f467698 --- /dev/null +++ b/test/fixtures/import/round-trip/model.sdp.ts.txt @@ -0,0 +1,20 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const modelRoundTripSpec = spec({ + id: specId("spec:round-trip.model"), + title: "Describe the order vocabulary", + kind: "model", + altitude: "story", + readiness: "defined", + relations: [], + intent: { + description: "The behavior needs a shared vocabulary.", + }, + model: { + description: "The minimal terms for order acceptance.", + terms: { + Order: "A submitted request for acceptance.", + Total: "The monetary amount the order presents.", + }, + }, +}); diff --git a/test/fixtures/import/round-trip/rule.sdp.ts.txt b/test/fixtures/import/round-trip/rule.sdp.ts.txt new file mode 100644 index 0000000..05360a2 --- /dev/null +++ b/test/fixtures/import/round-trip/rule.sdp.ts.txt @@ -0,0 +1,18 @@ +import { dependsOn, refines, spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const ruleRoundTripSpec = spec({ + id: specId("spec:round-trip.rule"), + title: "Keep the import rule explicit", + kind: "rule", + altitude: "story", + readiness: "idea", + relations: [ + refines(specId("spec:round-trip.behavior")), + dependsOn(specId("spec:round-trip.decision")), + dependsOn(specId("spec:round-trip.constraint")), + ], + behavior: { rules: ["Every supported field survives the carrier change."] }, + design: { importBoundary: "Reify before emitting." }, + verification: { mode: "contract", criteria: ["The graphs are equal."] }, + ui: { diagnostic: "Name the first divergent field." }, +}); diff --git a/test/fixtures/import/round-trip/workflow.sdp.ts.txt b/test/fixtures/import/round-trip/workflow.sdp.ts.txt new file mode 100644 index 0000000..00f93ae --- /dev/null +++ b/test/fixtures/import/round-trip/workflow.sdp.ts.txt @@ -0,0 +1,11 @@ +import { refines, spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const workflowRoundTripSpec = spec({ + id: specId("spec:round-trip.workflow"), + title: "Import a workflow", + kind: "workflow", + altitude: "story", + readiness: "idea", + relations: [refines(specId("spec:round-trip.behavior"))], + behavior: { flows: ["Reify the source.", "Emit the Markdown twin."] }, +}); diff --git a/test/import-barrel.test.ts b/test/import-barrel.test.ts new file mode 100644 index 0000000..44e8c05 --- /dev/null +++ b/test/import-barrel.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { + emitMarkdownSpec, + importFindingIds, + importTypeScriptSpec, + MarkdownEmissionError, +} from "../src/index.js"; +import type { ImportResult } from "../src/index.js"; + +describe("import barrel", () => { + it("exports the pure emit and import surface", () => { + const result: ImportResult = { findings: [] }; + + expect(emitMarkdownSpec).toBeTypeOf("function"); + expect(importTypeScriptSpec).toBeTypeOf("function"); + expect(MarkdownEmissionError).toBeTypeOf("function"); + expect(importFindingIds).toEqual({ + refusal: "import/refusal", + packUnsupported: "import/pack-unsupported", + targetExists: "import/target-exists", + unsupportedConstruct: "import/unsupported-construct", + invalidSourcePath: "import/invalid-source-path", + noSources: "import/no-sources", + empty: "import/empty", + }); + expect(result.findings).toEqual([]); + }); +}); diff --git a/test/import-emit-markdown.test.ts b/test/import-emit-markdown.test.ts new file mode 100644 index 0000000..5c3ef17 --- /dev/null +++ b/test/import-emit-markdown.test.ts @@ -0,0 +1,277 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { reifyMarkdownCarrier } from "../src/extract/markdown.js"; +import { emitMarkdownSpec } from "../src/import/emit-markdown.js"; +import type { ReifiedSpec } from "../src/extract/reify.js"; + +const checkoutSpecPaths = [ + "orders/create-order.sdp.md", + "orders/order-placement-flow.sdp.md", + "orders/create-order-valid-cart.sdp.md", + "orders/create-order-invalid-cart.sdp.md", + "orders/create-order-api-contract.sdp.md", + "orders/order-model.sdp.md", + "orders/order-total-rule.sdp.md", + "orders/order-inventory-rule.sdp.md", + "orders/order-latency-constraint.sdp.md", + "orders/order-management.sdp.md", + "decisions/order-lifecycle.sdp.md", +] as const; +const checkoutSpecRoot = new URL("../examples/checkout-v1/specs/", import.meta.url); + +function reifiedSpec(data: Record): ReifiedSpec { + return { + data: { + id: "spec:import.emitter", + kind: "behavior", + altitude: "story", + readiness: "idea", + title: "Emit a Markdown carrier", + relations: [], + ...data, + }, + id: "spec:import.emitter", + file: "source.sdp.ts", + line: 1, + }; +} + +function expectMarkdownRoundTrip(source: ReifiedSpec): string { + const emitted = emitMarkdownSpec(source); + const result = reifyMarkdownCarrier(emitted, "emitted.sdp.md"); + + expect(result.findings).toEqual([]); + expect(result.specs[0]?.data).toEqual(source.data); + return emitted; +} + +describe("emitMarkdownSpec", () => { + it("emits the empty envelope and maps title to the H1", () => { + const emitted = emitMarkdownSpec(reifiedSpec({})); + + expect(emitted).toBe(`--- +id: spec:import.emitter +kind: behavior +altitude: story +readiness: idea +relations: {} +--- +# Emit a Markdown carrier +`); + }); + + it("round trips envelope data and narrative through the Markdown carrier", () => { + const source = reifiedSpec({ + narrative: "The emitted document preserves author-owned prose.", + relations: [{ type: "refines", target: "spec:import.parent", claim: "declared" }], + }); + const emitted = emitMarkdownSpec(source); + const result = reifyMarkdownCarrier(emitted, "emitted.sdp.md"); + + expect(result.findings).toEqual([]); + expect(result.specs[0]?.data).toEqual(source.data); + expect(emitted).toContain( + "# Emit a Markdown carrier\n\nThe emitted document preserves author-owned prose.\n", + ); + expect(emitted).not.toContain("title:"); + expect(emitted).not.toContain("narrative:"); + }); + + it("emits Intent prose, fields, repeated entries, and owned open questions", () => { + const source = reifiedSpec({ + intent: { + description: "Intent prose belongs under Intent.", + actor: "An author", + problem: "Prose lacks a home.", + outcome: "Each prose edge has an owner.", + value: "The graph stays unambiguous.", + risks: ["A dropped field.", "An ambiguous paragraph."], + assumptions: ["The parser owns validation."], + openQuestions: [ + { question: "Is the section complete?", blocking: true }, + { question: "Should we add another example?", blocking: false }, + ], + }, + }); + + const emitted = expectMarkdownRoundTrip(source); + + expect(emitted).toContain("## Intent\n\nIntent prose belongs under Intent."); + expect(emitted).toContain("### Open questions\n\n- [blocking] Is the section complete?"); + }); + + it.each([ + [ + "Behavior", + { + behavior: { + description: "Behavior prose.", + rules: ["A behavior rule."], + flows: ["A behavior flow."], + }, + }, + "## Behavior\n\nBehavior prose.\n\n- rule: A behavior rule.\n- flow: A behavior flow.", + ], + [ + "Rule", + { kind: "rule", behavior: { rules: ["A plain rule."] } }, + "## Rule\n\n- A plain rule.", + ], + [ + "Workflow", + { kind: "workflow", behavior: { rules: ["A workflow rule."], flows: ["A plain flow."] } }, + "## Workflow\n\n- A plain flow.\n- rule: A workflow rule.", + ], + [ + "Contract", + { kind: "contract", behavior: { rules: ["A contract entry."] } }, + "## Contract\n\n- A contract entry.", + ], + ] as const)("emits the %s form in ruled list syntax", (_, data, expected) => { + expect(expectMarkdownRoundTrip(reifiedSpec(data))).toContain(expected); + }); + + it("emits example-space vocabulary and one immediately-owned example GWT fence", () => { + const space = reifiedSpec({ + behavior: { + description: "Vocabulary prose.", + exampleSpace: Object.fromEntries([ + ["given", ["a cart has {count:number} items"]], + ["when", ["the customer submits the cart"]], + ["t" + "hen", ["an order has {total:number}"]], + ]), + }, + }); + const example = reifiedSpec({ + kind: "example", + intent: { outcome: "Exercise one bound point." }, + behavior: { + examples: [ + Object.fromEntries([ + ["given", ["a cart has {count: 0} items"]], + ["when", ["the customer submits the cart"]], + ["t" + "hen", ["an order has {total: 0}"]], + ]), + ], + }, + }); + + const spaceEmitted = expectMarkdownRoundTrip(space); + const exampleEmitted = expectMarkdownRoundTrip(example); + + expect(spaceEmitted).toContain("## Example space\n\nVocabulary prose.\n\n```gwt-vocabulary"); + expect(exampleEmitted).toContain("- outcome: Exercise one bound point.\n\n```gwt"); + expect(exampleEmitted.match(/```gwt\n/gu)).toHaveLength(1); + }); + + it("emits the one-entry Constraints form and preserves parser refusal without its statement", () => { + const emitted = expectMarkdownRoundTrip( + reifiedSpec({ + constraints: [ + { + statement: "The output stays deterministic.", + flavor: "quality", + target: "bytes", + measurableBy: "a clean check", + }, + ], + }), + ); + const refused = reifyMarkdownCarrier( + emitted.replace("- statement: The output stays deterministic.\n", ""), + "refused.sdp.md", + ); + + expect(emitted).toContain("## Constraints\n\n- statement: The output stays deterministic."); + expect(refused.specs).toEqual([]); + expect(refused.findings).toContainEqual( + expect.objectContaining({ validatorId: "extract/invalid-markdown-structure" }), + ); + }); + + it("emits Model, Design, UI, Decision, and Verification owners in authored order", () => { + const source = reifiedSpec({ + model: { description: "Model prose.", terms: { Cart: "A cart.", Order: "An order." } }, + design: { description: "Design prose.", firstChoice: "Start.", secondChoice: "Continue." }, + ui: { description: "UI prose.", primaryAction: "Submit.", errorState: "Explain." }, + decision: { + description: "Decision prose.", + context: "Owners are needed.", + decision: "Emit once.", + rationale: ["The grammar is frozen."], + alternatives: ["Invent a section."], + consequences: ["Output is deterministic."], + }, + verification: { + description: "Verification prose.", + mode: "contract", + criteria: ["The document reifies."], + }, + }); + + const emitted = expectMarkdownRoundTrip(source); + + for (const heading of ["Model", "Design", "UI", "Decision", "Verification — contract"]) + expect(emitted).toContain(`## ${heading}`); + }); + + it("round trips all eleven migrated checkout Specs through the emitter", () => { + for (const path of checkoutSpecPaths) { + const source = readFileSync(fileURLToPath(new URL(path, checkoutSpecRoot)), "utf8"); + const markdown = reifyMarkdownCarrier(source, path); + const spec = markdown.specs[0]; + + expect(markdown.findings).toEqual([]); + expect(spec).toBeDefined(); + if (spec !== undefined) expectMarkdownRoundTrip(spec); + } + }); + + it("groups relation records in ruled key order and emits multiple targets as a sequence", () => { + const emitted = emitMarkdownSpec( + reifiedSpec({ + relations: [ + { type: "verifies", target: "spec:import.verifier", claim: "declared" }, + { type: "dependsOn", target: "spec:import.first-dependency", claim: "declared" }, + { type: "refines", target: "spec:import.parent", claim: "declared" }, + { type: "dependsOn", target: "spec:import.second-dependency", claim: "declared" }, + { type: "supersedes", target: "spec:import.previous", claim: "declared" }, + { type: "constrainedBy", target: "spec:import.constraint", claim: "declared" }, + { type: "decidedBy", target: "spec:import.decision", claim: "declared" }, + ], + }), + ); + + expect(emitted).toContain(`relations: + refines: spec:import.parent + dependsOn: + - spec:import.first-dependency + - spec:import.second-dependency + constrainedBy: spec:import.constraint + decidedBy: spec:import.decision + verifies: spec:import.verifier + supersedes: spec:import.previous +`); + expect(emitMarkdownSpec(reifiedSpec({ relations: [] }))).toBe( + emitMarkdownSpec(reifiedSpec({ relations: [] })), + ); + }); + + it("is refused when a valid emitted relation key is hand-corrupted", () => { + const emitted = emitMarkdownSpec( + reifiedSpec({ + relations: [{ type: "dependsOn", target: "spec:import.dependency", claim: "declared" }], + }), + ); + const corrupted = emitted.replace(" dependsOn:", " invalidRelation:"); + const result = reifyMarkdownCarrier(corrupted, "corrupted.sdp.md"); + + expect(result.specs).toEqual([]); + expect(result.findings).toContainEqual( + expect.objectContaining({ validatorId: "extract/invalid-frontmatter" }), + ); + }); +}); diff --git a/test/import-review-08.test.ts b/test/import-review-08.test.ts new file mode 100644 index 0000000..47f0493 --- /dev/null +++ b/test/import-review-08.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; + +import * as protocol from "../src/index.js"; +import { emitMarkdownSpec } from "../src/import/emit-markdown.js"; +import { importFindingIds, importTypeScriptSpec } from "../src/import/import.js"; +import { MarkdownEmissionError } from "../src/import/markdown-fidelity.js"; +import type { ReifiedSpec } from "../src/extract/reify.js"; + +function specSource(body: string, title = 'title: "Review-08 import",'): string { + return `import { refines, spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const reviewed = spec({ + id: specId("spec:import.review-08"), + ${title} + kind: "example", + altitude: "story", + readiness: "idea", + ${body} +}); +`; +} + +function packSource(): string { + return `import { pack, packId, ref } from "@libar-dev/software-delivery-protocol"; +export const reviewedPack = pack({ + id: packId("pack:import-review-08"), + specs: [ref("spec:import.review-08")], + modelRefs: [], +}); +`; +} + +describe("review-08 pure import regressions", () => { + it("refuses a title-less Spec with a precise authored path", () => { + const result = importTypeScriptSpec(specSource("", ""), "title-less.sdp.ts"); + const finding = result.findings.find( + (entry) => entry.validatorId === importFindingIds.unsupportedConstruct, + ); + + expect(result.emitted).toBeUndefined(); + expect(finding?.message).toContain("first divergent path: title"); + expect(finding?.message).not.toContain("# undefined"); + }); + + it.each([ + [ + "intent", + `behavior: { examples: [{ given: ["a cart"], when: ["submit"], then: ["an order"] }] },`, + ], + ["design", "design: {},"], + [ + "relations", + `relations: [ + refines("spec:import.parent"), + refines("spec:import.parent"), + ],`, + ], + ])("names the first divergent %s path", (path, body) => { + const result = importTypeScriptSpec(specSource(body), `${path}.sdp.ts`); + const finding = result.findings.find( + (entry) => entry.validatorId === importFindingIds.unsupportedConstruct, + ); + + expect(result.emitted).toBeUndefined(); + expect(finding?.message).toContain(`first divergent path: ${path}`); + }); + + it("refuses a mixed Spec and Pack carrier with an actionable error", () => { + const result = importTypeScriptSpec(`${specSource("")}\n${packSource()}`, "mixed.sdp.ts"); + const finding = result.findings.find( + (entry) => entry.validatorId === importFindingIds.packUnsupported, + ); + + expect(result.emitted).toBeUndefined(); + expect(finding).toMatchObject({ severity: "error" }); + expect(finding).not.toHaveProperty("line"); + expect(finding?.message).toContain("move the Spec to its own TypeScript carrier"); + expect(finding?.message).not.toContain("mixed.sdp.ts"); + }); + + it("retains the Pack finding beside a multi-Spec refusal", () => { + const secondSpec = specSource("") + .replaceAll("reviewed", "second") + .replaceAll("spec:import.review-08", "spec:import.review-08-second"); + const result = importTypeScriptSpec( + `${specSource("")}\n${secondSpec}\n${packSource()}`, + "multiple-with-pack.sdp.ts", + ); + + expect(result.findings.map((finding) => finding.validatorId)).toEqual([ + importFindingIds.packUnsupported, + importFindingIds.unsupportedConstruct, + ]); + expect(result.findings.every((finding) => finding.severity === "error")).toBe(true); + }); + + it("exports the typed emission error beside the public emitter", () => { + expect("MarkdownEmissionError" in protocol).toBe(true); + const lossy: ReifiedSpec = { + data: { + id: "spec:import.lossy", + title: "Lossy", + kind: "behavior", + altitude: "story", + readiness: "idea", + relations: [], + design: {}, + }, + id: "spec:import.lossy", + file: "lossy.sdp.ts", + line: 1, + }; + + expect(() => emitMarkdownSpec(lossy)).toThrow(MarkdownEmissionError); + }); +}); diff --git a/test/import-round-trip.helpers.ts b/test/import-round-trip.helpers.ts new file mode 100644 index 0000000..212108c --- /dev/null +++ b/test/import-round-trip.helpers.ts @@ -0,0 +1,80 @@ +import { expect } from "vitest"; + +import { + emitMarkdownSpec, + extract, + reifyMarkdownCarrier, + reifyTypeScriptCarrier, +} from "../src/index.js"; +import type { DeliveryFactName, GraphSchema, ReifiedSpec } from "../src/index.js"; + +function markdownSiblingPath(relativePath: string): string { + return relativePath.replace(/\.sdp\.ts$/u, ".sdp.md"); +} + +function normalizeCarrierPath(file: string): string { + return file.replace(/\.sdp\.(?:ts|md)$/u, ""); +} + +function oneSpec(specs: readonly ReifiedSpec[]): ReifiedSpec | undefined { + expect(specs).toHaveLength(1); + return specs[0]; +} + +export function assertAuthoredRoundTrip(tsSourceText: string, relativePath: string): void { + const tsReification = reifyTypeScriptCarrier(tsSourceText, relativePath); + expect(tsReification.findings).toEqual([]); + const tsSpec = oneSpec(tsReification.specs); + expect(tsSpec).toBeDefined(); + if (tsSpec === undefined) return; + + const markdownReification = reifyMarkdownCarrier( + emitMarkdownSpec(tsSpec), + markdownSiblingPath(relativePath), + ); + expect(markdownReification.findings).toEqual([]); + const markdownSpec = oneSpec(markdownReification.specs); + expect(markdownSpec).toBeDefined(); + if (markdownSpec === undefined) return; + + expect(markdownSpec.data).toEqual(tsSpec.data); +} + +function graphContent(graph: GraphSchema) { + const packIds = new Set( + graph.nodes.filter((node) => node.nodeType === "Pack").map((node) => node.id), + ); + const nodes = graph.nodes + .filter((node) => node.nodeType !== "Pack") + .map((node) => { + if (node.nodeType === "Primitive") { + const { deliveryFacts, file, ...content } = node; + void deliveryFacts; + return { ...content, file: normalizeCarrierPath(file) }; + } + + return { ...node, file: normalizeCarrierPath(node.file) }; + }); + const edges = graph.edges.filter((edge) => !packIds.has(edge.from) && !packIds.has(edge.to)); + + return { nodes, edges }; +} + +function deliveryFactsByNode(graph: GraphSchema): ReadonlyMap { + const facts = new Map(); + + for (const node of graph.nodes) { + if (node.nodeType === "Primitive") facts.set(node.id, node.deliveryFacts ?? []); + } + + return facts; +} + +export function assertGraphRoundTrip(tsCorpusDir: string, mdCorpusDir: string): void { + const typeScriptGraph = extract({ root: tsCorpusDir }).graph; + const markdownGraph = extract({ root: mdCorpusDir }).graph; + + expect(markdownGraph.schemaVersion).toBe(typeScriptGraph.schemaVersion); + expect(graphContent(markdownGraph)).toEqual(graphContent(typeScriptGraph)); + expect(deliveryFactsByNode(markdownGraph)).toEqual(deliveryFactsByNode(typeScriptGraph)); +} diff --git a/test/import-round-trip.test.ts b/test/import-round-trip.test.ts new file mode 100644 index 0000000..f8b27b7 --- /dev/null +++ b/test/import-round-trip.test.ts @@ -0,0 +1,119 @@ +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { emitMarkdownSpec, reifyTypeScriptCarrier } from "../src/index.js"; +import { assertAuthoredRoundTrip, assertGraphRoundTrip } from "./import-round-trip.helpers.js"; + +const fixtureRoot = fileURLToPath(new URL("./fixtures/import/round-trip", import.meta.url)); + +function materializeMarkdownCorpus(): { readonly tsRoot: string; readonly mdRoot: string } { + const root = mkdtempSync(join(tmpdir(), "sdp-import-round-trip-")); + const tsRoot = join(root, "typescript"); + const mdRoot = join(root, "markdown"); + cpSync(fixtureRoot, tsRoot, { recursive: true }); + mkdirSync(mdRoot, { recursive: true }); + + const specPaths = [ + "behavior.sdp.ts", + "example.sdp.ts", + "decision.sdp.ts", + "constraint.sdp.ts", + "model.sdp.ts", + "rule.sdp.ts", + "workflow.sdp.ts", + "contract.sdp.ts", + ] as const; + for (const relativePath of [...specPaths, "anchors.ts"]) { + renameSync(join(tsRoot, `${relativePath}.txt`), join(tsRoot, relativePath)); + } + + for (const relativePath of specPaths) { + const source = readFileSync(join(tsRoot, relativePath), "utf8"); + const markdownPath = join(mdRoot, relativePath.replace(/\.ts$/u, ".md")); + const reified = reifyTypeScriptCarrier(source, relativePath); + const spec = reified.specs[0]; + mkdirSync(dirname(markdownPath), { recursive: true }); + expect(reified.findings).toEqual([]); + expect(spec).toBeDefined(); + if (spec !== undefined) writeFileSync(markdownPath, emitMarkdownSpec(spec), "utf8"); + } + + writeFileSync( + join(mdRoot, "anchors.ts"), + readFileSync(join(tsRoot, "anchors.ts"), "utf8"), + "utf8", + ); + + return { tsRoot, mdRoot }; +} + +describe("import round-trip harness", () => { + it("preserves reified authored data through emitted Markdown", () => { + // Given + const relativePath = "behavior.sdp.ts"; + const source = readFileSync(join(fixtureRoot, `${relativePath}.txt`), "utf8"); + + // When + const roundTrip = () => { + assertAuthoredRoundTrip(source, relativePath); + }; + + // Then + expect(roundTrip).not.toThrow(); + }); + + it("preserves the mini-corpus graph under the declared delta catalog", () => { + // Given + const corpus = materializeMarkdownCorpus(); + + // When + const roundTrip = () => { + assertGraphRoundTrip(corpus.tsRoot, corpus.mdRoot); + }; + + // Then + try { + expect(roundTrip).not.toThrow(); + } finally { + rmSync(dirname(corpus.tsRoot), { recursive: true, force: true }); + } + }); + + it("rejects a corrupted Markdown twin", () => { + // Given + const corpus = materializeMarkdownCorpus(); + const corrupted = join(corpus.mdRoot, "behavior.sdp.md"); + writeFileSync( + corrupted, + readFileSync(corrupted, "utf8").replace( + "An order is accepted only when its total is positive.", + "An order is accepted even when its total is negative.", + ), + "utf8", + ); + + // When + const roundTrip = () => { + assertGraphRoundTrip(corpus.tsRoot, corpus.mdRoot); + }; + + // Then + try { + expect(roundTrip).toThrow(); + } finally { + rmSync(dirname(corpus.tsRoot), { recursive: true, force: true }); + } + }); +}); diff --git a/test/import.test.ts b/test/import.test.ts new file mode 100644 index 0000000..dc5778d --- /dev/null +++ b/test/import.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; + +import { reifyTypeScriptCarrier } from "../src/extract/carrier.js"; +import { emitMarkdownSpec } from "../src/import/emit-markdown.js"; +import { importFindingIds, importTypeScriptSpec } from "../src/import/import.js"; + +function specSource(id: string, title: string): string { + return `import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const authoredSpec = spec({ + id: specId("${id}"), + title: "${title}", + kind: "behavior", + altitude: "story", + readiness: "idea", +}); +`; +} + +function packSource(): string { + return `import { pack, packId, ref } from "@libar-dev/software-delivery-protocol"; + +export const authoredPack = pack({ + id: packId("pack:import-test"), + title: "Import test pack", + framing: "A Pack remains TypeScript-authored.", + specs: [ref("spec:import.healthy")], + modelRefs: [], +}); +`; +} + +describe("importTypeScriptSpec", () => { + it("emits one sibling Markdown document for a healthy TypeScript Spec", () => { + // Given + const relativePath = "specs/import.healthy.sdp.ts"; + const source = specSource("spec:import.healthy", "Import a healthy Spec"); + const reification = reifyTypeScriptCarrier(source, relativePath); + const spec = reification.specs[0]; + + // When + const result = importTypeScriptSpec(source, relativePath); + + // Then + expect(spec).toBeDefined(); + if (spec !== undefined) { + expect(result).toEqual({ + emitted: { + path: "specs/import.healthy.sdp.md", + content: emitMarkdownSpec(spec), + }, + findings: [], + }); + } + }); + + it("refuses a TypeScript carrier with reification errors and names its file", () => { + // Given + const relativePath = "specs/refused.sdp.ts"; + const source = "export const = ;"; + + // When + const result = importTypeScriptSpec(source, relativePath); + + // Then + expect(result).not.toHaveProperty("emitted"); + const refusal = result.findings.find( + (finding) => finding.validatorId === importFindingIds.refusal, + ); + + expect(refusal).toBeDefined(); + if (refusal !== undefined) { + expect(refusal.severity).toBe("error"); + expect(refusal.message).not.toContain(relativePath); + } + }); + + it("returns reifier findings unchanged when a carrier is refused", () => { + // Given + const relativePath = "specs/pass-through.sdp.ts"; + const source = "export const = ;"; + const reification = reifyTypeScriptCarrier(source, relativePath); + const reifierFinding = reification.findings[0]; + + // When + const result = importTypeScriptSpec(source, relativePath); + + // Then + expect(reifierFinding).toBeDefined(); + if (reifierFinding !== undefined) { + const importedFinding = result.findings.find( + (finding) => finding.validatorId === reifierFinding.validatorId, + ); + + expect(importedFinding).toEqual(reifierFinding); + expect(importedFinding).toMatchObject({ + validatorId: reifierFinding.validatorId, + file: reifierFinding.file, + line: reifierFinding.line, + }); + } + }); + + it("reports a Pack-only module as unsupported without emitting Markdown", () => { + // Given + const relativePath = "specs/import-test.pack.sdp.ts"; + + // When + const result = importTypeScriptSpec(packSource(), relativePath); + + // Then + expect(result).not.toHaveProperty("emitted"); + expect(result.findings).toContainEqual( + expect.objectContaining({ + validatorId: importFindingIds.packUnsupported, + severity: "error", + }), + ); + }); + + it("reports an empty module without emitting Markdown", () => { + // Given + const relativePath = "specs/empty.sdp.ts"; + + // When + const result = importTypeScriptSpec("", relativePath); + + // Then + expect(result).not.toHaveProperty("emitted"); + const emptyFinding = result.findings.find( + (finding) => finding.validatorId === importFindingIds.empty, + ); + + expect(emptyFinding).toBeDefined(); + if (emptyFinding !== undefined) { + expect(emptyFinding.severity).toBe("error"); + expect(emptyFinding.message).not.toContain(relativePath); + } + }); + + it("refuses a mixed Spec and Pack module before publishing a duplicate carrier", () => { + // Given + const relativePath = "specs/mixed.sdp.ts"; + const source = `${specSource("spec:import.mixed", "Import a mixed module")}\n${packSource()}`; + + // When + const result = importTypeScriptSpec(source, relativePath); + + // Then + expect(result.emitted).toBeUndefined(); + expect(result.findings).toContainEqual( + expect.objectContaining({ + validatorId: importFindingIds.packUnsupported, + severity: "error", + }), + ); + }); + + it("refuses a module that reifies more than one Spec", () => { + // Given + const relativePath = "specs/multiple.sdp.ts"; + const source = `${specSource("spec:import.first", "First imported Spec")}\n${specSource("spec:import.second", "Second imported Spec")}`; + + // When + const result = importTypeScriptSpec(source, relativePath); + + // Then + expect(result).not.toHaveProperty("emitted"); + const unsupportedConstruct = result.findings.find( + (finding) => finding.validatorId === importFindingIds.unsupportedConstruct, + ); + + expect(unsupportedConstruct).toBeDefined(); + if (unsupportedConstruct !== undefined) { + expect(unsupportedConstruct.severity).toBe("error"); + expect(unsupportedConstruct.message).not.toContain(relativePath); + } + }); + + it.each([ + [ + "multiple constraints", + `constraints: [{ statement: "First constraint." }, { statement: "Second constraint." }]`, + ], + ["a prose open question", `intent: { openQuestions: ["Must not disappear."] }`], + [ + "multiple example actions", + `behavior: { examples: [{ given: ["a cart"], when: ["submit", "retry"], then: ["an order"] }] }`, + ], + ["a structural title", undefined], + ])("refuses %s when Markdown cannot preserve its authored data", (name, detail) => { + // Given: a TypeScript Spec shape the Markdown grammar cannot preserve exactly. + const source = `import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const authoredSpec = spec({ + id: specId("spec:import.lossy"), + title: ${JSON.stringify(name === "a structural title" ? "Import a Spec\n\n## Intent\n- outcome: injected" : "Import a lossy Spec")}, + kind: "example", + altitude: "story", + readiness: "idea", + ${detail ?? ""} +}); +`; + + // When: the public import seam reifies the TypeScript carrier. + const result = importTypeScriptSpec(source, "specs/lossy.sdp.ts"); + + // Then: it refuses rather than emitting a changed Markdown document. + expect(result).not.toHaveProperty("emitted"); + expect(result.findings).toContainEqual( + expect.objectContaining({ validatorId: importFindingIds.unsupportedConstruct }), + ); + }); + + it("refuses a path that cannot produce a Markdown sibling", () => { + // Given: valid TypeScript carrier text with a non-carrier path. + const source = specSource("spec:import.invalid-path", "Reject an invalid source path"); + + // When: a library consumer uses the public import seam. + const result = importTypeScriptSpec(source, "specs/invalid.ts"); + + // Then: it cannot return the source path as a write target. + expect(result).not.toHaveProperty("emitted"); + expect(result.findings).toContainEqual( + expect.objectContaining({ validatorId: importFindingIds.invalidSourcePath }), + ); + }); +}); diff --git a/test/markdown-grammar-hardening.test.ts b/test/markdown-grammar-hardening.test.ts new file mode 100644 index 0000000..3b9c5ad --- /dev/null +++ b/test/markdown-grammar-hardening.test.ts @@ -0,0 +1,229 @@ +import { expect, it, vi } from "vitest"; + +import { reifyMarkdownCarrier } from "../src/index.js"; + +function reify(sourceText: string) { + return reifyMarkdownCarrier(sourceText, "grammar-hardening.sdp.md"); +} + +const validCarrier = `--- +id: spec:carrier.grammar-hardening +kind: behavior +altitude: story +readiness: idea +relations: {} +--- +# Grammar hardening`; + +function nestedRelation(levels: number): string { + return `--- +id: spec:carrier.depth +kind: behavior +altitude: story +readiness: idea +relations: + dependsOn: +${Array.from({ length: levels }, (_, index) => `${" ".repeat(4 + index * 2)}-`).join("\n")} +${" ".repeat(4 + levels * 2)}spec:carrier.target +--- +# Grammar hardening`; +} + +function relationTargets(count: number): string { + return `--- +id: spec:carrier.nodes +kind: behavior +altitude: story +readiness: idea +relations: + dependsOn: +${Array.from({ length: count }, (_, index) => ` - spec:${String(index)}`).join("\n")} +--- +# Grammar hardening`; +} + +it.each(["1e3", "0x1F", "-.inf"])( + "classifies the YAML 1.2 plain scalar %s as a non-string scalar", + (spelling) => { + const result = reify(validCarrier.replace("readiness: idea", `readiness: ${spelling}`)); + + expect(result.specs).toEqual([]); + expect(result.findings).toContainEqual( + expect.objectContaining({ + validatorId: "extract/invalid-frontmatter", + line: 5, + message: 'frontmatter field "readiness" must be a string scalar', + }), + ); + }, +); + +it("rebases YAML-native diagnostic line text with the structured finding line", () => { + const result = reify( + validCarrier.replace( + "id: spec:carrier.grammar-hardening", + "id: &a: spec:carrier.grammar-hardening", + ), + ); + const finding = result.findings.find((entry) => entry.message.startsWith("Anchor ending in :")); + + expect(finding).toMatchObject({ + validatorId: "extract/invalid-frontmatter", + line: 2, + }); + expect(finding?.message).toContain("at line 2, column 7"); +}); + +it("refuses a YAML document-end marker before the required frontmatter closer", () => { + const result = reify(validCarrier.replace("relations: {}\n---", "relations: {}\n...\n---")); + + expect(result.specs).toEqual([]); + expect(result.findings).toEqual([ + expect.objectContaining({ + validatorId: "extract/invalid-frontmatter", + line: 1, + message: "frontmatter does not accept an exact ... document-end line", + }), + ]); +}); + +it("preserves accumulated YAML findings when the frontmatter root is not a mapping", () => { + const result = reify(`--- +%FOO bar +scalar +--- +# Grammar hardening`); + + expect(result.specs).toEqual([]); + expect(result.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ message: "YAML directives are not accepted" }), + expect.objectContaining({ message: "frontmatter must be a mapping" }), + ]), + ); +}); + +it.each([ + [nestedRelation(30), "frontmatter exceeds the depth limit of 16"], + [relationTargets(2_050), "frontmatter exceeds the 2,000 node limit"], +])("reports each frontmatter resource-limit breach once", (sourceText, message) => { + const result = reify(sourceText); + + expect(result.specs).toEqual([]); + expect(result.findings.filter((finding) => finding.message === message)).toHaveLength(1); +}); + +it("item f refuses an example GWT fence before the end of its Intent content", () => { + const result = reify( + validCarrier.replace("kind: behavior", "kind: example").replace( + "# Grammar hardening", + `# Grammar hardening +## Intent +\`\`\`gwt +Given valid input +When it is processed +Then output exists +\`\`\` +- outcome: This field is too late.`, + ), + ); + + expect(result.specs).toEqual([]); + expect(result.findings).toContainEqual( + expect.objectContaining({ + validatorId: "extract/invalid-markdown-structure", + message: "the example gwt fence must immediately follow the Intent block", + }), + ); +}); + +it("item f accepts trailing ASCII whitespace on the Open questions heading", () => { + const result = reify( + validCarrier.replace( + "# Grammar hardening", + `# Grammar hardening +## Intent +- outcome: Preserve the trim rule. +### Open questions \t +- [blocking] Is trailing whitespace trimmed?`, + ), + ); + + expect(result.findings).toEqual([]); + expect(result.specs[0]?.data.intent).toMatchObject({ + openQuestions: [{ question: "Is trailing whitespace trimmed?", blocking: true }], + }); +}); + +it("item f treats a terminal hash as literal H1 title text", () => { + const result = reify(validCarrier.replace("# Grammar hardening", "# Support for C#")); + + expect(result.findings).toEqual([]); + expect(result.specs[0]?.data.title).toBe("Support for C#"); +}); + +it("item f routes a terminal hash in H2 text through owner recognition", () => { + const result = reify( + validCarrier.replace("# Grammar hardening", "# Grammar hardening\n## Intent#"), + ); + const finding = result.findings.find( + (entry) => entry.validatorId === "extract/unrecognized-heading", + ); + + expect(result.specs).toEqual([]); + expect(finding).toBeDefined(); + expect(finding?.message).toContain('heading "Intent#" is not recognized'); +}); + +it("item g reports a duplicate When step exactly once", () => { + const result = reify( + validCarrier.replace("kind: behavior", "kind: example").replace( + "# Grammar hardening", + `# Grammar hardening +## Intent +\`\`\`gwt +Given valid input +When it is processed +When it is processed again +Then output exists +\`\`\``, + ), + ); + + expect(result.specs).toEqual([]); + expect(result.findings).toEqual([ + expect.objectContaining({ + validatorId: "extract/invalid-markdown-structure", + message: "a GWT fence has exactly one When step", + }), + ]); +}); + +it("item h converts an unexpected body-parser throw into a finding", async () => { + vi.resetModules(); + vi.doMock("../src/extract/markdown-body.js", () => ({ + parseMarkdownBody: () => { + throw new Error("unexpected body failure"); + }, + })); + + try { + const dynamicApi = await import("../src/index.js"); + const result = dynamicApi.reifyMarkdownCarrier(validCarrier, "grammar-hardening.sdp.md"); + + expect(result).toEqual({ + specs: [], + packs: [], + findings: [ + expect.objectContaining({ + validatorId: "extract/invalid-frontmatter", + line: 1, + message: "the carrier could not be reified: unexpected body failure", + }), + ], + }); + } finally { + vi.doUnmock("../src/extract/markdown-body.js"); + vi.resetModules(); + } +}); diff --git a/test/markdown-reifier.test.ts b/test/markdown-reifier.test.ts index 7e46d19..6361a7d 100644 --- a/test/markdown-reifier.test.ts +++ b/test/markdown-reifier.test.ts @@ -236,7 +236,7 @@ relations: expect(malformed.specs).toEqual([]); expect(malformed.findings).toHaveLength(100); expect(malformed.findings[99]).toMatchObject({ - validatorId: "extract/invalid-markdown-structure", + validatorId: "extract/invalid-frontmatter", line: 1, message: "finding limit reached; additional findings suppressed", }); @@ -268,7 +268,7 @@ relations: validatorId: "extract/invalid-frontmatter", line: 2, message: - "Anchor ending in : is ambiguous at line 1, column 7:\n\nid: &a: spec:carrier.warning\n ^\n", + "Anchor ending in : is ambiguous at line 2, column 7:\n\nid: &a: spec:carrier.warning\n ^\n", }); }); @@ -654,7 +654,7 @@ export const prose = spec({ expect(typeScriptResult.findings).toEqual([]); expect(markdownSerialized).toBe(typeScriptSerialized); - const serialized = JSON.parse(markdownSerialized) as unknown; + const serialized: unknown = JSON.parse(markdownSerialized); if (!isRecord(serialized) || !isRecordArray(serialized.nodes)) { throw new Error("serialized graph must contain a primitive node"); @@ -794,7 +794,7 @@ export const invalid = spec({ id: specId("spec:carrier.invalid-prose"), title: " carrierBody("# "), "extract/invalid-markdown-structure", 8, - "the H1 title must be nonempty and carry no closing marker", + "the H1 title must be nonempty and start immediately after #", ], [ "a heading beyond suggestion distance two", diff --git a/test/markdown-review-08.test.ts b/test/markdown-review-08.test.ts new file mode 100644 index 0000000..17140cb --- /dev/null +++ b/test/markdown-review-08.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; + +import { reifyMarkdownCarrier } from "../src/index.js"; + +function markdown(frontmatterEntry: string, body = ""): string { + return `--- +id: spec:markdown.review-08 +kind: behavior +altitude: story +readiness: idea +relations: {} +${frontmatterEntry} +--- +# Review-08 Markdown +${body}`; +} + +describe("review-08 Markdown diagnostics", () => { + it.each(["implemented", "has-verifier", "hasVerifier", "observed"])( + "classifies frontmatter delivery fact %s as reserved", + (name) => { + const result = reifyMarkdownCarrier(markdown(`${name}: true`), `${name}.sdp.md`); + + expect(result.specs).toEqual([]); + expect(result.findings).toContainEqual( + expect.objectContaining({ validatorId: "extract/reserved-property" }), + ); + }, + ); + + it("keeps a frontmatter finding id on the finding-cap summary", () => { + const unknownKeys = Array.from( + { length: 110 }, + (_, index) => `unknown${String(index)}: value`, + ).join("\n"); + const result = reifyMarkdownCarrier(markdown(unknownKeys), "finding-cap.sdp.md"); + + expect(result.findings).toHaveLength(100); + expect(result.findings.at(-1)).toMatchObject({ + validatorId: "extract/invalid-frontmatter", + message: "finding limit reached; additional findings suppressed", + }); + }); + + it("points a non-leading YAML directive at the directive line", () => { + const result = reifyMarkdownCarrier( + `--- +id: spec:markdown.review-08 +%YAML 1.2 +kind: behavior +altitude: story +readiness: idea +relations: {} +--- +# Review-08 Markdown +`, + "directive.sdp.md", + ); + const directive = result.findings.find((finding) => + finding.message.includes("YAML directives are not accepted"), + ); + + expect(directive?.line).toBe(3); + }); + + it("accepts inherited object names as first authored Design keys and Model terms", () => { + const result = reifyMarkdownCarrier( + markdown( + "", + ` +## Design + +- constructor: A real design field. + +## Model + +- **constructor** — A real domain term. +`, + ), + "inherited-names.sdp.md", + ); + + expect(result.findings).toEqual([]); + expect(result.specs[0]?.data).toMatchObject({ + design: { constructor: "A real design field." }, + model: { terms: { constructor: "A real domain term." } }, + }); + }); + + it("retains __proto__ as an authored Model term", () => { + const result = reifyMarkdownCarrier( + markdown( + "", + ` +## Model + +- **__proto__** — Something authored. +`, + ), + "proto-model-term.sdp.md", + ); + const model = result.specs[0]?.data.model; + + if ( + model === undefined || + model === null || + typeof model !== "object" || + !("terms" in model) || + model.terms === null || + typeof model.terms !== "object" + ) + throw new Error("expected Model terms to reify as an object"); + const terms = model.terms; + + expect(result.findings).toEqual([]); + expect(Object.hasOwn(terms, "__proto__")).toBe(true); + expect(Object.getOwnPropertyDescriptor(terms, "__proto__")?.value).toBe("Something authored."); + }); + + it("reports duplicate __proto__ Model terms", () => { + const result = reifyMarkdownCarrier( + markdown( + "", + ` +## Model + +- **__proto__** — First authored definition. +- **__proto__** — Duplicate authored definition. +`, + ), + "duplicate-proto-model-terms.sdp.md", + ); + + expect(result.findings).toContainEqual( + expect.objectContaining({ message: "model terms must be unique" }), + ); + }); +}); diff --git a/test/package-smoke.test.ts b/test/package-smoke.test.ts index b148f0c..c412115 100644 --- a/test/package-smoke.test.ts +++ b/test/package-smoke.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -8,6 +8,7 @@ import { describe, expect, it } from "vitest"; const packageName = "@libar-dev/software-delivery-protocol"; const repositoryRoot = fileURLToPath(new URL("..", import.meta.url)); +const importFixture = join(repositoryRoot, "test/fixtures/import/round-trip/behavior.sdp.ts.txt"); const expectedRootExports = [ "CODE_ANCHOR_NAMESPACES", @@ -35,6 +36,7 @@ const expectedRootExports = [ "deriveReadiness", "derivedEdgeTypes", "duplicateIdExclusionAnchor", + "emitMarkdownSpec", "evaluateReadinessFloor", "extract", "extractAnchor", @@ -48,9 +50,12 @@ const expectedRootExports = [ "graphReportId", "graphValidatorIds", "hasUnboundSlot", + "importFindingIds", + "importTypeScriptSpec", "isEnabledExampleVerify", "isResolvingTestAnchorVerify", "kindEvidence", + "MarkdownEmissionError", "oracleAnchorId", "pack", "packId", @@ -82,6 +87,7 @@ const expectedRootExports = [ const typeOnlyRootExports = [ "CarrierReification", "CarrierReifier", + "ImportResult", "ReifiedAnchor", "ReifiedPack", "ReifiedSpec", @@ -134,7 +140,7 @@ console.log( } describe("published package surface", () => { - it("resolves every public root export without the repository source alias", async () => { + it("proves the installed tarball lists import, dry-runs conversion without writes, and exposes the barrel", async () => { const packageRoot = await mkdtemp(join(tmpdir(), "sdp-package-smoke-")); const consumer = join(packageRoot, "consumer"); @@ -157,8 +163,15 @@ describe("published package surface", () => { run( "npm", - ["install", "--ignore-scripts", "--omit=dev", join(packageRoot, tarball)], - consumer, + [ + "install", + "--ignore-scripts", + "--omit=dev", + join(packageRoot, tarball), + "--prefix", + consumer, + ], + repositoryRoot, ); await writeFile(join(consumer, "consumer.mjs"), consumerDriver()); await writeFile( @@ -186,14 +199,31 @@ void [${expectedRootExports.join(", ")}]; consumer, ); - const driver = run(process.execPath, ["consumer.mjs"], consumer); const sdpHelp = run(join(consumer, "node_modules", ".bin", "sdp"), ["--help"], consumer); + await copyFile(importFixture, join(consumer, "behavior.sdp.ts")); + const sdpImportDryRun = run( + join(consumer, "node_modules", ".bin", "sdp"), + ["import", "--dry-run", "behavior.sdp.ts"], + consumer, + ); + const driver = run(process.execPath, ["consumer.mjs"], consumer); + const barrelCheck = run( + process.execPath, + [ + "-e", + `const m=require("${packageName}"); if(typeof m.emitMarkdownSpec!=="function"||typeof m.importTypeScriptSpec!=="function"||typeof m.importFindingIds!=="object"||typeof m.MarkdownEmissionError!=="function") process.exit(1); console.log("barrel imports available")`, + ], + consumer, + ); expect(JSON.parse(driver)).toEqual({ exports: [...expectedRootExports].sort(), nodes: ["spec:package.typescript", "spec:package.markdown"], }); - expect(sdpHelp).toContain("Usage:"); + expect(sdpHelp).toContain("sdp import"); + expect(sdpImportDryRun).toContain("id: spec:round-trip.behavior"); + expect(await readdir(consumer)).not.toContain("behavior.sdp.md"); + expect(barrelCheck).toBe("barrel imports available\n"); } finally { await rm(packageRoot, { force: true, recursive: true }); } diff --git a/test/reader.test.ts b/test/reader.test.ts index 1b9a247..f2fa57f 100644 --- a/test/reader.test.ts +++ b/test/reader.test.ts @@ -191,8 +191,8 @@ describe("the reader — the thin typed loader behind the agent surface", () => describe("byFile — the grep→graph bridge from a file", () => { it("maps a spec file to the spec authored there", () => { - expect(exampleReader().byFile("specs/orders/create-order.sdp.ts")).toEqual({ - path: "specs/orders/create-order.sdp.ts", + expect(exampleReader().byFile("specs/orders/create-order.sdp.md")).toEqual({ + path: "specs/orders/create-order.sdp.md", nodes: [{ id: "spec:orders.create-order", nodeType: "Primitive" }], specs: ["spec:orders.create-order"], }); @@ -293,12 +293,12 @@ describe("the reader — the thin typed loader behind the agent surface", () => }); it("treats a changed spec file as direct impact (no binding in the reason)", () => { - const radius = exampleReader().blastRadius(["specs/orders/create-order.sdp.ts"]); + const radius = exampleReader().blastRadius(["specs/orders/create-order.sdp.md"]); expect(radius.impactedSpecs).toEqual([ { id: "spec:orders.create-order", - reasons: [{ file: "specs/orders/create-order.sdp.ts" }], + reasons: [{ file: "specs/orders/create-order.sdp.md" }], }, ]); }); @@ -328,13 +328,13 @@ describe("the reader — the thin typed loader behind the agent surface", () => it("normalizes, dedupes, and sorts the changed-file list", () => { const radius = exampleReader().blastRadius([ - "./specs/orders/create-order.sdp.ts", - "specs/orders/create-order.sdp.ts", + "./specs/orders/create-order.sdp.md", + "specs/orders/create-order.sdp.md", "src/orders/pricing.ts", ]); expect(radius.changedFiles).toEqual([ - "specs/orders/create-order.sdp.ts", + "specs/orders/create-order.sdp.md", "src/orders/pricing.ts", ]); }); @@ -398,7 +398,7 @@ describe("the reader — the thin typed loader behind the agent surface", () => claim: "declared", enabled: false, label: "Invalid cart is rejected", - file: "specs/orders/create-order-invalid-cart.sdp.ts", + file: "specs/orders/create-order-invalid-cart.sdp.md", }, { verifierId: "spec:orders.create-order.valid-cart", @@ -406,7 +406,7 @@ describe("the reader — the thin typed loader behind the agent surface", () => claim: "declared", enabled: true, label: "Valid cart creates an order", - file: "specs/orders/create-order-valid-cart.sdp.ts", + file: "specs/orders/create-order-valid-cart.sdp.md", }, ]); diff --git a/test/self-hosting-contracts.test.ts b/test/self-hosting-contracts.test.ts index a98796f..40ccaf0 100644 --- a/test/self-hosting-contracts.test.ts +++ b/test/self-hosting-contracts.test.ts @@ -14,7 +14,10 @@ const repoRoot = fileURLToPath(new URL("..", import.meta.url)); describe("the self-hosting duplicate-ID example contracts", () => { it("carries the exact duplicate-ID example-space vocabulary", () => { // Given: the root corpus with the duplicate-ID rule. - const result = extract({ root: repoRoot, exclude: ["explorations", "examples"] }); + const result = extract({ + root: repoRoot, + exclude: ["explorations", "examples", "test/fixtures/import/parity"], + }); const index = buildGraphIndex(result.graph); const parent = index.primitivesById.get("spec:validation.duplicate-ids"); @@ -42,7 +45,10 @@ describe("the self-hosting duplicate-ID example contracts", () => { it("binds one ready point with distinct refines and verifies relations", () => { // Given: the root corpus and the parent vocabulary it owns. - const result = extract({ root: repoRoot, exclude: ["explorations", "examples"] }); + const result = extract({ + root: repoRoot, + exclude: ["explorations", "examples", "test/fixtures/import/parity"], + }); const index = buildGraphIndex(result.graph); const point = index.primitivesById.get("spec:validation.duplicate-ids.dual-carrier"); @@ -68,7 +74,10 @@ describe("the self-hosting duplicate-ID example contracts", () => { it("derives the bound space and step contracts", () => { // Given: the root corpus carries the duplicate-ID vocabulary and its bound point. - const result = extract({ root: repoRoot, exclude: ["explorations", "examples"] }); + const result = extract({ + root: repoRoot, + exclude: ["explorations", "examples", "test/fixtures/import/parity"], + }); // When: contracts are generated from the root graph. const generated = generateContracts(result.graph); diff --git a/test/self-hosting-duplicate-ids.test.ts b/test/self-hosting-duplicate-ids.test.ts index 4d5347b..5d2cf0c 100644 --- a/test/self-hosting-duplicate-ids.test.ts +++ b/test/self-hosting-duplicate-ids.test.ts @@ -191,7 +191,10 @@ describe("pooled root generated-state sentinel", () => { }); it("derives the duplicate-ID verifier facts from the bound executable example", () => { - const result = extract({ root: repoRoot, exclude: ["explorations", "examples"] }); + const result = extract({ + root: repoRoot, + exclude: ["explorations", "examples", "test/fixtures/import/parity"], + }); const child = result.graph.nodes.find( (node) => node.nodeType === "Primitive" && node.id === dualCarrierContract.spec, ); diff --git a/test/self-hosting-graph.test.ts b/test/self-hosting-graph.test.ts index 1ed02b5..f591157 100644 --- a/test/self-hosting-graph.test.ts +++ b/test/self-hosting-graph.test.ts @@ -66,6 +66,17 @@ const expectedSpecs = [ behavior: { rules: [ "The parser accepts only the ruled heading grammar and excludes one malformed carrier while continuing healthy siblings.", + "The ruled Markdown parser has bounded finding-class parity with the TypeScript carrier for `extract/non-static-envelope`, `extract/invalid-id`, `extract/duplicate-id`, `extract/reserved-property`, `extract/unowned-prose`, and `extract/unrecognized-property`; the shared validator ID is the claim, while severity and extract-versus-refuse outcomes remain carrier-specific.", + "Named non-claim — `extract/parse-error` remains distinct because YAML/frontmatter parsing has no TypeScript parser-diagnostic analogue.", + "Named non-claim — `extract/non-static-section` remains distinct because TypeScript degrades optional section properties while Markdown refuses malformed documents whole.", + "Named non-claim — `extract/unrecognized-statement` remains distinct because Markdown owns prose and structures, not TypeScript statement recognition.", + "Named non-claim — `extract/misplaced-authoring` remains distinct because Markdown has no executable authoring-call surface.", + ], + }, + verification: { + mode: "executable", + criteria: [ + "`test/extract-parity.test.ts` executes the settled finding-class parity matrix, including the six same-class findings, their carrier-specific outcomes, and four named non-claims.", ], }, }, @@ -75,14 +86,65 @@ const expectedSpecs = [ id: "spec:carrier.sdp-import", specKind: "behavior", altitude: "feature", - readiness: "idea", + readiness: "ready", file: "specs/carrier/sdp-import.sdp.md", - title: "Existing intent can later be imported into the ruled carrier", + title: "TypeScript-carried Specs can become Markdown twins", narrative: null, sections: { - intent: { outcome: "Name import as deferred work without claiming an emitter exists." }, + intent: { + actor: "A coding agent or maintainer.", + outcome: + "Convert a TypeScript-carrier Spec into an idiomatic `.sdp.md` twin beside its source.", + value: + "The TypeScript DSL survives as an import source while Markdown becomes the authored twin.", + }, + behavior: { + rules: [ + "Import writes the emitted Markdown sibling beside the TypeScript carrier and never deletes the source carrier.", + "Import refuses an existing Markdown sibling rather than overwriting it.", + "Refusal outcomes retain the TypeScript reifier findings and add import-local findings honestly.", + "Import consumes the TypeScript reifier so source acceptance follows one validation path.", + "A batch scans only bounded source directories, canonicalizes physical carrier identity, and computes every refusal and target collision before publishing any sibling.", + "Publication prepares exclusive temporary siblings and atomically creates targets without clobbering; rollback attempts every artifact, reports survivors, and never deletes a TypeScript source.", + ], + exampleSpace: { + given: ["a TS-carrier spec"], + when: ["importTypeScriptSpec runs"], + [["t", "hen"].join("")]: ["the emitted Markdown re-parses to an equal graph"], + }, + }, }, - deliveryFacts: [], + deliveryFacts: ["has-verifier"], + }, + { + id: "spec:carrier.sdp-import.round-trip", + specKind: "example", + altitude: "story", + readiness: "ready", + file: "specs/carrier/sdp-import.round-trip.sdp.md", + title: "A TypeScript carrier survives import as an equal Markdown graph", + narrative: null, + sections: { + intent: { + outcome: "Execute the import round-trip against a TypeScript-carrier fixture.", + }, + behavior: { + examples: [ + { + given: ["a TS-carrier spec"], + when: ["importTypeScriptSpec runs"], + [["t", "hen"].join("")]: ["the emitted Markdown re-parses to an equal graph"], + }, + ], + }, + verification: { + mode: "executable", + criteria: [ + "The bound test runs `assertAuthoredRoundTrip` against the import behavior fixture.", + ], + }, + }, + deliveryFacts: ["has-verifier"], }, { id: "spec:carrier.prose-ownership-rule", @@ -129,11 +191,18 @@ const expectedSpecs = [ readiness: "ready", file: "specs/extraction/derive-graph.sdp.md", title: "Carrier reification derives the one graph", - narrative: null, + narrative: + "The graph is the current projection of the repository at a commit. Git holds lifecycle history, so removed records disappear from the current graph and a current `supersedes` relation is the only forward pointer between records that still exist.", sections: { intent: { outcome: "Expose one carrier-neutral derivation seam." }, behavior: { - rules: ["Carrier reification feeds deriveGraph once; no consumer creates a second graph."], + rules: [ + "Carrier reification feeds deriveGraph once; no consumer creates a second graph.", + "The graph is flat arrays of typed nodes and edges; hierarchy and containment are expressed by edges rather than nested nodes.", + "Declared relations resolve Primitive to Primitive, while `satisfies` and test `verifies` edges derive from anchors and run from their binding node to the direct Spec target.", + "Delivery facts are computed node facts: a resolving `satisfies` edge contributes `implemented`, and an enabled direct verifier contributes `has-verifier` only to its target.", + "Inferred structural edges are advisory inputs to impact analysis and never become authoritative graph truth.", + ], }, }, deliveryFacts: ["implemented", "has-verifier"], @@ -157,168 +226,1271 @@ const expectedSpecs = [ measurableBy: "test/cli.test.ts clean-repo determinism", }, ], + behavior: { + rules: [ + "Nodes sort by ID, edges sort by from, type, and to, and semantically compared output excludes wall-clock timestamps and run-specific hashes.", + "`sdp build --check-clean` repeats extraction and contract generation independently, failing on any graph or generated-contract byte divergence.", + "Static envelope fields fail extraction when they cannot be reified; optional TypeScript section detail may warn and drop, while Markdown documents refuse as a whole.", + ], + }, + }, + deliveryFacts: ["has-verifier"], + }, + { + id: "spec:extraction.excludes", + specKind: "rule", + altitude: "feature", + readiness: "defined", + file: "specs/extraction/excludes.sdp.md", + title: "Extraction exclusions are strict consumer input", + narrative: null, + sections: { + intent: { + outcome: + "Keep consumer-selected omissions precise without changing the extractor's canonical discovery rules.", + }, + behavior: { + rules: [ + "An exclusion is a unique, exact root-relative POSIX path prefix applied to both declared-carrier and anchor-candidate discovery surfaces.", + "A prefix excludes itself and slash-delimited descendants only; it never excludes a merely similar sibling path.", + "Empty, dot-relative, absolute, Windows-drive, backslash, trailing-slash, and parent-traversal paths are refused rather than normalized into a different meaning.", + ], + }, + }, + deliveryFacts: ["implemented"], + }, + { + id: "spec:decisions.exclusion-contract", + specKind: "decision", + altitude: "feature", + readiness: "defined", + file: "specs/decisions/exclusion-contract.sdp.md", + title: "Consumer exclusions stay exact", + narrative: null, + sections: { + intent: { + outcome: "Keep consumer-selected omissions precise and unsurprising.", + }, + decision: { + context: "Exclusion input crosses from a consumer into canonical source discovery.", + decision: "Consumers declare exclusions as exact root-relative POSIX path prefixes.", + rationale: [ + "Semantic globbing and path normalization are rejected because they make an omission broader or different from the path the consumer supplied.", + ], + consequences: [ + "A prefix excludes only itself and slash-delimited descendants; malformed paths, including Windows-drive absolutes, are refused.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:extraction.claim-taxonomy", + specKind: "model", + altitude: "feature", + readiness: "defined", + file: "specs/extraction/claim-taxonomy.sdp.md", + title: "Graph claims retain their epistemic source", + narrative: null, + sections: { + intent: { + outcome: + "Let every graph reader distinguish authored intent, human bindings, and machine-derived structure.", + }, + model: { + terms: { + declared: + "Human intent explicitly authored in a Spec or Pack; it is authoritative intent.", + anchored: + "A human binding from a code, test, or oracle location to one Spec ID; it is authoritative binding and carries no intent.", + inferred: + "Machine-derived structural information; it is advisory and never authoritative.", + "claim inheritance": + "An edge computed from an authored source retains that source's declared claim; derivation is a mechanism, not a fourth claim.", + "delivery fact": + "A realization signal computed from resolving edges, never an authored claim or edge.", + }, + }, + }, + deliveryFacts: ["implemented"], + }, + { + id: "spec:extraction.regenerability", + specKind: "rule", + altitude: "feature", + readiness: "defined", + file: "specs/extraction/regenerability.sdp.md", + title: "Generated artifacts are disposable projections", + narrative: null, + sections: { + intent: { + outcome: + "Keep the repository canonical while allowing every graph and projection to be rebuilt safely.", + }, + behavior: { + rules: [ + "Generated artifacts are disposable: deleting them and rebuilding from the same committed repository produces the same bytes.", + "Consumers read the graph or link to source locations recorded in it; they never re-parse source or keep a parallel model.", + "The graph is a single JSON projection with in-memory query support; a graph database remains deferred until measured traversal pain establishes a real need.", + "Measured evidence from the self-hosting corpus keeps full rebuilds comfortable below roughly 50 Specs.", + "Measured evidence defers a graph database until the graph reaches roughly 10k+ nodes or traversal pain establishes a real need.", + ], + }, + }, + deliveryFacts: ["implemented"], + }, + { + id: "spec:extraction.schema-versioning", + specKind: "rule", + altitude: "story", + readiness: "defined", + file: "specs/extraction/schema-versioning.sdp.md", + title: "The graph declares its schema version", + narrative: null, + sections: { + intent: { + outcome: + "Let consumers identify the graph payload contract without premature migration machinery.", + }, + behavior: { + rules: [ + "Every graph declares its schemaVersion, and MVP consumers require that field to be present and readable.", + "Envelope-stable, section-extensible growth is normally additive; SemVer negotiation and a migration command remain deferred until a consumer needs them.", + ], + }, + }, + deliveryFacts: ["implemented"], + }, + { + id: "spec:extraction.executable-contracts", + specKind: "behavior", + altitude: "feature", + readiness: "defined", + file: "specs/extraction/executable-contracts.sdp.md", + title: "The build derives executable contracts from graph examples", + narrative: null, + sections: { + intent: { + outcome: + "Give bound tests typed step and example-space contracts without reading authored Specs directly.", + }, + behavior: { + rules: [ + "`generateContracts` derives per-example step contracts and per-parent space contracts solely from the extracted graph.", + "A generated contract is disposable, keyed by Spec ID, and becomes unavailable when its authored example cannot bind honestly to its shared vocabulary.", + ], + }, + }, + deliveryFacts: ["implemented"], + }, + { + id: "spec:extraction.build-pipeline", + specKind: "workflow", + altitude: "feature", + readiness: "defined", + file: "specs/extraction/build-pipeline.sdp.md", + title: "The build pipeline has one ordered flow", + narrative: null, + sections: { + intent: { outcome: "Turn authored carriers into validated derived artifacts." }, + behavior: { + rules: ["Every command uses the same extracted graph and validation seam."], + flows: [ + "Discover carriers.", + "Reify carriers.", + "Derive the graph.", + "Validate the graph.", + "Emit derived artifacts.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:validation.readiness-floor", + specKind: "rule", + altitude: "feature", + readiness: "ready", + file: "specs/validation/readiness-floor.sdp.md", + title: "Stated readiness must clear its floor", + narrative: null, + sections: { + intent: { outcome: "Refuse maturity claims that their authored evidence does not support." }, + behavior: { + rules: [ + "A Spec may state a readiness only when every clause in that readiness floor passes.", + ], + }, + }, + deliveryFacts: ["implemented", "has-verifier"], + }, + { + id: "spec:validation.duplicate-ids", + specKind: "behavior", + altitude: "feature", + readiness: "ready", + file: "specs/validation/duplicate-ids.sdp.md", + title: "Duplicate carrier IDs are excluded loudly", + narrative: null, + sections: { + intent: { outcome: "Prevent ambiguous authored identity from entering the graph." }, + behavior: { + rules: [ + "If more than one carrier declares an ID, every duplicate site receives extract/duplicate-id and no ambiguous node is derived.", + ], + exampleSpace: { + given: [ + "a {firstCarrier:string} carrier declares {specId:string}", + "a {secondCarrier:string} carrier declares {specId:string}", + ], + when: ["the extraction root is read"], + [["t", "hen"].join("")]: [ + "both sites report {findingId:string}", + "no graph node is emitted for {specId:string}", + ], + }, + }, + }, + deliveryFacts: ["implemented", "has-verifier"], + }, + { + id: "spec:validation.duplicate-ids.dual-carrier", + specKind: "example", + altitude: "story", + readiness: "ready", + file: "specs/validation/duplicate-ids.dual-carrier.sdp.md", + title: "TypeScript and Markdown duplicates are both refused", + narrative: null, + sections: { + intent: { outcome: "Execute the duplicate-ID rule across both carrier surfaces." }, + behavior: { + examples: [ + { + given: [ + 'a {firstCarrier: "TypeScript"} carrier declares {specId: "spec:fixture.duplicate"}', + 'a {secondCarrier: "Markdown"} carrier declares {specId: "spec:fixture.duplicate"}', + ], + when: ["the extraction root is read"], + [["t", "hen"].join("")]: [ + 'both sites report {findingId: "extract/duplicate-id"}', + 'no graph node is emitted for {specId: "spec:fixture.duplicate"}', + ], + }, + ], + }, + }, + deliveryFacts: ["has-verifier"], + }, + { + id: "spec:model.protocol-domain", + specKind: "model", + altitude: "feature", + readiness: "defined", + file: "specs/model/protocol-domain.sdp.md", + title: "The Protocol domain uses one ratified language", + narrative: null, + sections: { + intent: { outcome: "Give self-hosting specs the same core vocabulary." }, + model: { + terms: { + Pack: "A grouping and review aggregate that states no system truth.", + Spec: "The one authored truth-primitive.", + anchor: "An in-code identity binding that states no intent.", + "delivery fact": "A machine-derived realization signal.", + }, + }, + }, + deliveryFacts: [], + }, + { + id: "spec:decisions.plain-language-references", + specKind: "decision", + altitude: "feature", + readiness: "defined", + file: "specs/decisions/plain-language-references.sdp.md", + title: "Durable references lead with meaning", + narrative: null, + sections: { + intent: { + outcome: "Keep design rationale readable without decoding registries.", + }, + decision: { + context: "Decision codes are useful lookup keys but poor standalone prose.", + decision: + "Durable references lead with plain-language meaning; decision codes follow parenthetically when useful.", + rationale: ["Meaning survives registry churn."], + consequences: ["AGENTS and plans lead with names."], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:decisions.concept-docs-dissolve", + specKind: "decision", + altitude: "feature", + readiness: "defined", + file: "specs/decisions/concept-docs-dissolve.sdp.md", + title: "Concept documents may dissolve after executable truth lands", + narrative: null, + sections: { + intent: { + outcome: "Keep intended truth authoritative while allowing exposition to shrink.", + }, + decision: { + context: "Concept documents currently carry both laws and unsettled representation.", + decision: + "Concept documents may dissolve only after their semantic contract is carried by executable Specs and lean registries.", + rationale: ["Executable truth is easier to validate and consume."], + consequences: ["Deletion is later work, never part of phase 1."], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:model.core-model", + specKind: "model", + altitude: "feature", + readiness: "defined", + file: "specs/model/core-model.sdp.md", + title: "The Protocol models delivery with one enrichable Spec", + narrative: null, + sections: { + intent: { + outcome: + "Give every authored delivery statement one stable shape and independent coordinates.", + }, + model: { + terms: { + Spec: "The one authored truth-primitive, enriched in place without changing artifact type.", + altitude: "The scope position `epic`, `feature`, or `story`.", + "delivery fact": + "A derived realization signal such as implemented or has-verifier; it is never authored readiness.", + envelope: + "The stable outer shape of id, title, kind, altitude, readiness, and relations; sections carry extension detail.", + kind: "The true subtype that categorizes a Spec's truth and changes its required detail and validation.", + readiness: + "The author-stated design-maturity position `idea`, `scoped`, `defined`, or `ready`, checked against a structural floor.", + }, + }, + }, + deliveryFacts: ["implemented"], + }, + { + id: "spec:model.spec-sections", + specKind: "model", + altitude: "feature", + readiness: "defined", + file: "specs/model/spec-sections.sdp.md", + title: "Spec sections carry typed detail and direct verifier semantics", + narrative: null, + sections: { + intent: { + outcome: + "Extend Specs with local detail without weakening their envelope or confusing binding evidence with intent.", + }, + model: { + terms: { + "content-only section": + "A section carries local content, while relations carry links to promoted standalone Specs.", + "enabled verifier": + "An example or direct test with a linked, resolvable test anchor; runner execution and pass state remain outside the graph.", + promotion: + "Moving shared or independently reviewed content into a standalone Spec of the matching kind, exclusively rather than alongside inline content.", + section: + "An optional detail slice of a Spec: intent, behavior, constraints, model, design, decision, verification, or ui.", + "typing law": + "Every section read by a readiness-floor clause has a closed typed shape; unsettled design and ui surfaces remain open bags.", + verifies: + "A direct verifier-to-target relation whose enabled test binding can derive has-verifier only for that stated target.", + }, + }, + }, + deliveryFacts: ["implemented"], + }, + { + id: "spec:model.relations", + specKind: "model", + altitude: "feature", + readiness: "defined", + file: "specs/model/relations.sdp.md", + title: "Specs declare typed directed relations", + narrative: null, + sections: { + intent: { + outcome: + "Preserve the explicit intent links that make a delivery model navigable and queryable.", + }, + model: { + terms: { + "authored relation": "A declared, directed Spec-to-Spec edge that records human intent.", + constrainedBy: "A bounded Spec points to its rule, constraint, or policy Spec.", + decidedBy: "A shaped Spec points to its Decision Record.", + dependsOn: "A dependent Spec points to the Spec it needs.", + refines: "A child points to its more precise parent.", + supersedes: "A current Decision Record points forward to the decision it replaces.", + verifies: "A verifier points to the Spec it verifies.", + }, + }, + }, + deliveryFacts: ["implemented"], + }, + { + id: "spec:model.stable-ids", + specKind: "rule", + altitude: "story", + readiness: "defined", + file: "specs/model/stable-ids.sdp.md", + title: "Stable IDs are the Protocol's durable join key", + narrative: null, + sections: { + intent: { + outcome: + "Keep intent, bindings, and graph nodes connected through names that survive code refactoring.", + }, + behavior: { + rules: [ + "A Protocol ID is stable, unique, namespaced, human-readable, and the only binding between intent and code.", + "An ID uses a lowercase namespace and dotted path, with an optional single `#` sub-part; referential-integrity checks reject malformed or unresolved references.", + "IDs carry no history: a rename is a repository edit recorded by git rather than graph-resident bookkeeping.", + ], + }, + }, + deliveryFacts: ["implemented"], + }, + { + id: "spec:model.pack-aggregate", + specKind: "model", + altitude: "story", + readiness: "defined", + file: "specs/model/pack-aggregate.sdp.md", + title: "A Pack is a truth-free review aggregate", + narrative: null, + sections: { + intent: { + outcome: + "Let reviewers group related Specs without introducing a second truth-bearing artifact.", + }, + model: { + terms: { + Pack: "An authored aggregate that groups related Specs for ideation and review while stating no system truth of its own.", + framing: "A plain descriptive note explaining why a Pack exists; it is not Spec intent.", + membership: + "A declared manifest reference that derives a belongsTo edge; a Spec may belong to many Packs.", + modelRefs: + "References from a Pack to standalone model Specs that carry shared vocabulary.", + refinement: + "A truth-bearing parent-child relation, distinct from the cross-cutting Pack aggregate.", + }, + }, + }, + deliveryFacts: ["implemented"], + }, + { + id: "spec:model.anchors", + specKind: "model", + altitude: "feature", + readiness: "defined", + file: "specs/model/anchors.sdp.md", + title: "Source anchors bind code without carrying intent", + narrative: null, + sections: { + intent: { + outcome: + "Connect implementation, tests, and oracles to Specs while keeping authored intent centralized in the carrier.", + }, + model: { + terms: { + "Protocol builder binding": + "A builder import from the public Protocol package, or a relative import whose importer-relative resolution — including the TypeScript `.js`-to-`.ts` convention — canonicalizes to this package's `ids` or `model/code-anchor` module; consumer-local lookalike modules confer no binding authority. On the CommonJS package surface the trusted relative-module set is empty (`import.meta.url` is rewritten away), so relative bindings mint no anchors there while package imports stay trusted.", + anchor: + "A human-written source binding from one code location to one Spec ID, carrying identity, an optional label, and one target only.", + "anchor-constant form": + "The top-level const builder call that the MVP extractor reifies; decorator and JSDoc forms remain unextracted representations.", + "code anchor": + "An implementation-flavored binding that derives an anchored satisfies edge.", + "oracle anchor": + "A binding that records an oracle's models target without deriving a delivery fact.", + "test anchor": + "A binding that derives an anchored verifies edge from a test to its target Spec.", + }, + }, + }, + deliveryFacts: ["implemented"], + }, + { + id: "spec:validation.two-check-families", + specKind: "rule", + altitude: "feature", + readiness: "defined", + file: "specs/validation/two-check-families.sdp.md", + title: "Validation separates well-formedness from non-pretending", + narrative: null, + sections: { + intent: { + outcome: + "Keep the graph trustworthy by checking conformance and honesty without judging content quality or enforcing workflow.", + }, + behavior: { + rules: [ + "Every validator belongs to either the conformance family, which checks meta-model well-formedness, or the honesty family, which rejects authored or overstated derived truth.", + "Validation errors fail the build; gaps and orphans remain informative signals rather than delivery-process gates.", + "Types enforce structural shape, schema validates graph payloads, and graph validators enforce cross-file conformance and honesty; no one layer substitutes for the others.", + "All graph validation runs through the one derived graph path: source, extraction, graph, then checks.", + ], + }, + }, + deliveryFacts: ["implemented"], + }, + { + id: "spec:validation.referential-integrity", + specKind: "rule", + altitude: "story", + readiness: "defined", + file: "specs/validation/referential-integrity.sdp.md", + title: "Every graph reference resolves", + narrative: null, + sections: { + intent: { + outcome: + "Keep derived graph relationships trustworthy by refusing references to absent nodes.", + }, + behavior: { + rules: [ + "Every edge endpoint and every Pack model reference must resolve to a node in the derived graph; an unresolved reference is a conformance error.", + "The realizing validator entrypoint is `checkReferentialIntegrity` in `src/validate/validators.ts`.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:validation.claim-separation", + specKind: "rule", + altitude: "story", + readiness: "defined", + file: "specs/validation/claim-separation.sdp.md", + title: "Graph claims and contracts stay distinct", + narrative: null, + sections: { + intent: { + outcome: + "Preserve the graph's declared, anchored, and inferred distinctions while keeping its typed contracts lawful.", + }, + behavior: { + rules: [ + "Node and edge types, claims, descriptors, and relation endpoint contracts must use their ratified forms; the claim taxonomy never collapses.", + "The realizing validator entrypoint is `checkClaimSeparation` in `src/validate/validators.ts`.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:validation.verification-linkage", + specKind: "rule", + altitude: "feature", + readiness: "defined", + file: "specs/validation/verification-linkage.sdp.md", + title: "Declared verification resolves to a performing trace", + narrative: null, + sections: { + intent: { + outcome: + "Keep verification relationships meaningful by requiring declared test and oracle traces to resolve to their enabled bindings.", + }, + behavior: { + rules: [ + "A declared verifies relation and an oracle model relation must resolve through their respective binding traces before either can stand as verification evidence.", + "The realizing validator entrypoints are `checkVerifiesLinkage` and `checkOracleLinkage` in `src/validate/validators.ts`.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:validation.pack-coherence", + specKind: "rule", + altitude: "story", + readiness: "defined", + file: "specs/validation/pack-coherence.sdp.md", + title: "Packs are coherent aggregates", + narrative: null, + sections: { + intent: { + outcome: + "Keep review aggregates coherent without treating them as truth-bearing delivery artifacts.", + }, + behavior: { + rules: [ + "Pack membership must not repeat a Spec, and every modelRef must resolve to a model-kind Spec.", + "The realizing validator entrypoint is `checkPackCoherence` in `src/validate/validators.ts`.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:validation.authored-honesty", + specKind: "rule", + altitude: "feature", + readiness: "defined", + file: "specs/validation/authored-honesty.sdp.md", + title: "Machine truth is never authored", + narrative: null, + sections: { + intent: { + outcome: + "Keep derived graph truth trustworthy by rejecting any authored substitute for machine-derived claims or facts.", + }, + behavior: { + rules: [ + "Specs and Packs must not author derived edges, claims, or delivery facts, and any stated delivery facts must equal the graph's recomputed facts.", + "The realizing validator entrypoints are `checkAuthoringShape` and `checkDeliveryFacts` in `src/validate/validators.ts`.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:validation.warn-level-signals", + specKind: "rule", + altitude: "feature", + readiness: "defined", + file: "specs/validation/warn-level-signals.sdp.md", + title: "Missing connective evidence warns without failing", + narrative: null, + sections: { + intent: { + outcome: + "Surface graph conditions that need attention without turning informative delivery signals into workflow gates.", + }, + behavior: { + rules: [ + "Orphaned Specs and ready Specs lacking a resolving verifier are warnings, not validation errors.", + "The realizing validator entrypoints are `checkOrphans` and `checkGaps` in `src/validate/validators.ts`.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:consumers.projections-model", + specKind: "model", + altitude: "feature", + readiness: "defined", + file: "specs/consumers/projections-model.sdp.md", + title: "Projections fan out from one graph without becoming truth stores", + narrative: null, + sections: { + intent: { + outcome: + "Give agents and humans consumer-specific views while preserving the repository as the only canonical source.", + }, + model: { + terms: { + baseline: + "A named approved snapshot whose signed git tag is the approval artifact, with approval remaining outside the authored model.", + "curated graph": + "The authored architectural read model of declared intent and anchored bindings, valued for editorial sparsity.", + curation: + "The deliberate difference between the sparse curated graph and the code-structure surface; it is not drift.", + discipline: + "A lens or projection that filters or groups Specs by kind or section; it is not a phase to pass through.", + "measured curation": + "In a measured comparison, the curated graph selected from single-digit to about one quarter of the mechanical impact-graph surface.", + "impact graph": + "A separately derived code-structure surface for exhaustive usage and blast-radius questions, valued for exhaustiveness and never promoted into architecture.", + "phase / iteration / milestone": + "Descriptive vocabulary for optional roadmap projections, never gates or enforced sequences.", + projection: + "A pure, disposable, regenerable function of the graph that produces a consumer artifact without becoming a second source of truth.", + reader: + "The thin typed front door that decodes graph joins and taxonomy once, returns composable data, and persists nothing.", + release: "A tagged set surfaced as a git-tag projection.", + }, + }, + }, + deliveryFacts: ["implemented"], + }, + { + id: "spec:consumers.agent-surface", + specKind: "behavior", + altitude: "feature", + readiness: "defined", + file: "specs/consumers/agent-surface.sdp.md", + title: "Agents script a visible typed graph", + narrative: null, + sections: { + intent: { + outcome: + "Let an agent obtain and compose graph context without rebuilding joins or navigating a fixed verb wall.", + }, + behavior: { + rules: [ + "The agent surface exposes a visible, self-describing typed graph through the CLI; the schema is the contract and agents script the graph directly.", + "The reader constructs decoded joins and claim taxonomy once, then returns plain composable data without persisting graph state.", + "Entry adapters bridge strings, files, and changesets to curated graph context; file-level blast radius names coverage-unknown files rather than implying exhaustive reach.", + "Context efficiency is an empirical result: a measured comparison may show structured graph context uses fewer supplied tokens than a comparable raw-text workflow while preserving the task-relevant result.", + "Measured evidence: a multi-probe agent comparison used about one fifth of the tokens of a comparable grep or verb-API workflow while preserving task-relevant conclusions.", + ], + }, + }, + deliveryFacts: ["implemented"], + }, + { + id: "spec:consumers.design-review", + specKind: "behavior", + altitude: "feature", + readiness: "defined", + file: "specs/consumers/design-review.sdp.md", + title: "Design Review renders graph context without becoming a gate", + narrative: null, + sections: { + intent: { + outcome: + "Give a human a regenerable, contextual view for deciding how to state readiness without recording approval as graph truth.", + }, + behavior: { + rules: [ + "Design Review renders a Spec or Pack in context with relations, bindings, delivery badges, design questions, and findings from the graph.", + "The review is a pure projection that resolves through ordinary source edits, git, and conformance checks; it stores no findings and writes no canonical source.", + "A human may use the review context when stating readiness, while validators check only the structural readiness floor and never record or require review approval.", + "The MVP view is deterministic generated Markdown with an index and pages for Specs and Packs; richer visual representations remain outside this behavior.", + "Rendering encodes by Markdown syntax context: prose and table fields escape structural characters, fenced JSON preserves authored keys and values through JSON encoding, and inline code uses a delimiter that preserves literal backticks.", + ], + }, + }, + deliveryFacts: ["implemented"], + }, + { + id: "spec:consumers.reader", + specKind: "behavior", + altitude: "feature", + readiness: "defined", + file: "specs/consumers/reader.sdp.md", + title: "The reader bridges agent entry points to composable graph context", + narrative: null, + sections: { + intent: { + outcome: + "Let agents enter the curated graph from the strings, files, and changesets they already have without rebuilding its joins or taxonomy.", + }, + behavior: { + rules: [ + "`createReader` constructs a fresh thin typed loader that decodes graph joins, claims, delivery facts, derived readiness, and validation findings once, then returns plain composable data without persisting state.", + "`findByConcept` and `byFile` bridge strings and extraction-root-relative files to the graph's recorded context.", + "The reader's `blastRadius` surface maps changed files to directly impacted Specs and Packs, their explicit one-hop at-risk neighbors, and every coverage-unknown file.", + "File-level blast radius reports curated graph reach without claiming exhaustive symbol-level usage reach.", + ], + }, + }, + deliveryFacts: ["implemented"], + }, + { + id: "spec:consumers.edit-model", + specKind: "behavior", + altitude: "feature", + readiness: "defined", + file: "specs/consumers/edit-model.sdp.md", + title: "Views compose scoped intent instead of patching canonical source", + narrative: null, + sections: { + intent: { + outcome: + "Let a view frame a requested change without giving derived surfaces a direct write path to canonical source.", + }, + behavior: { + rules: [ + "A view composes scoped intent, bounded by a Spec, its neighbors, a Pack, or open questions, and hands that intent to an agent.", + "The agent edits source as a human would, git records the ordinary edit, and the same conformance and honesty checks evaluate it.", + "Lifecycle changes such as splitting, combining, refining, or deleting are ordinary source and git edits rather than structured patches from a derived view.", + "No single realizing entrypoint exists for intent composition; this defined behavior records design intent and has no code anchor or verifier.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:decisions.one-validation-path", + specKind: "decision", + altitude: "feature", + readiness: "defined", + file: "specs/decisions/one-validation-path.sdp.md", + title: "Validation follows one graph", + narrative: null, + sections: { + intent: { + outcome: + "Keep conformance and honesty checks aligned with the source the graph actually represents.", + }, + decision: { + context: + "Source can be statically reified without matching what an executing import would evaluate.", + decision: + "Validators consume the derived graph through one path: source, extraction, graph, then checks.", + rationale: [ + "A parallel import-time validation path can approve values absent from the graph.", + ], + consequences: [ + "Typed authoring feedback and extraction findings remain distinct from graph validation rather than becoming a second validator.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:decisions.sdp-ts-extension", + specKind: "decision", + altitude: "feature", + readiness: "defined", + file: "specs/decisions/sdp-ts-extension.sdp.md", + title: "Spec extensions identify the carrier without colliding with tests", + narrative: null, + sections: { + intent: { + outcome: + "Keep authored Spec files recognizable to tools and safe beside ordinary test conventions.", + }, + decision: { + context: + "A carrier filename must distinguish authored Specs from test files and remain useful when files are colocated.", + decision: + "Markdown Specs use `.sdp.md`; `.sdp.ts` names the surviving TypeScript DSL import source and lawful per-ID option.", + rationale: [ + "Test-glob extensions and path-only conventions either misclassify Specs or hide their identity.", + ], + consequences: [ + "Carrier-specific tooling can target the compound extension without changing the `Spec` model name.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:decisions.point-per-example", + specKind: "decision", + altitude: "feature", + readiness: "defined", + file: "specs/decisions/point-per-example.sdp.md", + title: "Each example binds one point", + narrative: null, + sections: { + intent: { + outcome: + "Keep example-space coverage and outcome witnesses unambiguous while preserving compact authoring views.", + }, + decision: { + context: "A single example must remain one witness in its parent's typed example space.", + decision: + "An example binds exactly one point; table syntax may expand statically into sibling examples and renderers may project siblings as a table.", + rationale: [ + "Point sets make concreteness and witness semantics conditional, while banning table sugar taxes a surface layer that can translate honestly.", + ], + consequences: [ + "The graph never stores multi-point examples even when a carrier offers tabular authoring.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:decisions.carrier-ruling", + specKind: "decision", + altitude: "feature", + readiness: "defined", + file: "specs/decisions/carrier-ruling.sdp.md", + title: "Markdown is the default Spec carrier", + narrative: null, + sections: { + intent: { + outcome: + "Give every Spec kind one readable canonical authoring surface without losing a lawful escape hatch.", + }, + decision: { + context: + "The carrier must express all Spec kinds without creating an unbounded tooling obligation or a dual-source truth path.", + decision: + "Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option.", + rationale: [ + "An owned grammar and a permanent kind split both add surface cost without a demonstrated expressive gain, while retiring the DSL removes a useful bounded option.", + ], + consequences: [ + "Each ID has one canonical surface, and Markdown tooling is the default path for authored Specs.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:decisions.prose-ownership", + specKind: "decision", + altitude: "feature", + readiness: "defined", + file: "specs/decisions/prose-ownership.sdp.md", + title: "Prose belongs to typed graph owners", + narrative: null, + sections: { + intent: { + outcome: + "Preserve free prose for projections without making its attachment ambiguous or forcing consumers to re-parse files.", + }, + decision: { + context: "Document prose needs a stable graph home when section structure evolves.", + decision: + "Free prose is stored as a narrative or a description on its typed owner; unowned prose is refused.", + rationale: [ + "File pointers force consumer re-parsing, while heading-path keys make churned document structure carry identity.", + ], + consequences: [ + "Prose remains graph content inside typed shapes and ambiguous attachment fails loudly.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:decisions.envelope-grammar-posture", + specKind: "decision", + altitude: "feature", + readiness: "defined", + file: "specs/decisions/envelope-grammar-posture.sdp.md", + title: "The Protocol owns the envelope grammar", + narrative: null, + sections: { + intent: { + outcome: + "Keep authored envelope meaning stable while retaining a replaceable parsing representation.", + }, + decision: { + context: "YAML parsing behavior alone cannot define the Protocol's authored contract.", + decision: + "The Protocol owns a bounded envelope grammar and parser policy; the pinned YAML library is a swappable representation behind that contract.", + rationale: [ + "Permissive parsing lets library behavior define meaning, while an owned YAML parser recreates the rejected grammar-maintenance burden.", + ], + consequences: [ + "Unsupported YAML constructs are refused within explicit resource bounds instead of silently becoming carrier semantics.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:decisions.executable-meta-model", + specKind: "decision", + altitude: "feature", + readiness: "defined", + file: "specs/decisions/executable-meta-model.sdp.md", + title: "The Protocol is an executable meta-model", + narrative: null, + sections: { + intent: { outcome: "Make delivery intent conform to one typed, self-validating contract." }, + decision: { + context: "Delivery tools can describe work without making their model executable.", + decision: + "The Protocol models authored Specs, Packs, and anchors in typed code, derives one graph, and checks conformance and honesty.", + rationale: ["Executable specs alone and workflow tooling omit the meta-model contract."], + consequences: [ + "The Protocol is deterministically validated without judging content quality or enforcing workflow.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:decisions.adopt-the-nouns", + specKind: "decision", + altitude: "feature", + readiness: "defined", + file: "specs/decisions/adopt-the-nouns.sdp.md", + title: "Delivery nouns remain familiar without workflow gates", + narrative: null, + sections: { + intent: { + outcome: + "Keep the Protocol legible to delivery practitioners without adopting a lifecycle machine.", + }, + decision: { + context: + "Shared delivery vocabulary is useful, but process-state language hides epistemic distinctions.", + decision: + "The Protocol adopts established delivery nouns and rejects process state-machine and lifecycle gating.", + rationale: [ + "Invented terminology taxes users, while workflow states reverse the Protocol's conformance-only boundary.", + ], + consequences: [ + "Terms must be concrete, unambiguous, and carry authored-versus-derived status where it matters.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:decisions.one-primitive", + specKind: "decision", + altitude: "feature", + readiness: "defined", + file: "specs/decisions/one-primitive.sdp.md", + title: "One Spec carries named delivery coordinates", + narrative: null, + sections: { + intent: { + outcome: + "Preserve one durable authored primitive while making familiar delivery forms precise.", + }, + decision: { + context: + "Delivery statements vary by truth category, scope, and maturity without needing separate artifact types.", + decision: + "A Spec is enriched in place with kind, altitude, and readiness; familiar delivery nouns are named coordinates on that primitive.", + rationale: [ + "Separate types per coordinate combination multiply shapes and break enrich-in-place identity.", + ], + consequences: ["Domains and capabilities are projections or Packs, not extra altitudes."], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:decisions.protocol-naming", + specKind: "decision", + altitude: "feature", + readiness: "defined", + file: "specs/decisions/protocol-naming.sdp.md", + title: "The meta-model is a software delivery protocol", + narrative: null, + sections: { + intent: { outcome: "Name the product and its meta-layer without implying workflow control." }, + decision: { + context: + "The meta-layer needs a name that communicates a conformance contract rather than a process engine.", + decision: + "The product is the Libar Software Delivery Protocol, shortened to the Protocol; `sdp` names its CLI.", + rationale: [ + "Protocol names an executable conformance contract more honestly than process while retaining process for the modeled activity.", + ], + consequences: [ + "Product, package, repository, and CLI names stay aligned around the Protocol.", + ], + }, }, - deliveryFacts: ["has-verifier"], + deliveryFacts: [], }, { - id: "spec:extraction.build-pipeline", - specKind: "workflow", + id: "spec:decisions.binding-not-liveness", + specKind: "decision", altitude: "feature", readiness: "defined", - file: "specs/extraction/build-pipeline.sdp.md", - title: "The build pipeline has one ordered flow", + file: "specs/decisions/binding-not-liveness.sdp.md", + title: "Bindings state existence, not liveness", narrative: null, sections: { - intent: { outcome: "Turn authored carriers into validated derived artifacts." }, - behavior: { - rules: ["Every command uses the same extracted graph and validation seam."], - flows: [ - "Discover carriers.", - "Reify carriers.", - "Derive the graph.", - "Validate the graph.", - "Emit derived artifacts.", + intent: { + outcome: "Make realization signals useful without overstating what source bindings prove.", + }, + decision: { + context: + "Anchors can resolve code and tests without proving reachability, execution, or approval.", + decision: + "Delivery facts record bindings and enabled verifier existence; coverage gaps and human readiness practice remain explicit without becoming graph facts.", + rationale: [ + "Renaming useful delivery facts or recording approval primitives either weakens drift signals or reverses the one-primitive boundary.", + ], + consequences: [ + "Impact reports name coverage-unknown files and `ready` remains a declared statement above a structural floor.", ], }, }, deliveryFacts: [], }, { - id: "spec:validation.readiness-floor", - specKind: "rule", + id: "spec:decisions.content-only-sections", + specKind: "decision", altitude: "feature", - readiness: "ready", - file: "specs/validation/readiness-floor.sdp.md", - title: "Stated readiness must clear its floor", + readiness: "defined", + file: "specs/decisions/content-only-sections.sdp.md", + title: "Sections carry content while relations carry links", narrative: null, sections: { - intent: { outcome: "Refuse maturity claims that their authored evidence does not support." }, - behavior: { - rules: [ - "A Spec may state a readiness only when every clause in that readiness floor passes.", + intent: { + outcome: "Keep inline detail and promoted Specs from representing the same fact twice.", + }, + decision: { + context: + "Behavior content can mature from prose to structured evidence or into a standalone matching-kind Spec.", + decision: + "Sections contain local content only; promotion moves content exclusively and relations state the linkage.", + rationale: [ + "Reference unions and duplicate parent lists force consumers to branch and leave double-linkage drift legal.", + ], + consequences: [ + "Promoted children preserve readiness evidence through their own content and authored relations.", ], }, }, - deliveryFacts: ["implemented", "has-verifier"], + deliveryFacts: [], }, { - id: "spec:validation.duplicate-ids", - specKind: "behavior", + id: "spec:decisions.typing-law", + specKind: "decision", altitude: "feature", - readiness: "ready", - file: "specs/validation/duplicate-ids.sdp.md", - title: "Duplicate carrier IDs are excluded loudly", + readiness: "defined", + file: "specs/decisions/typing-law.sdp.md", + title: "Floor-read sections are closed typed shapes", narrative: null, sections: { - intent: { outcome: "Prevent ambiguous authored identity from entering the graph." }, - behavior: { - rules: [ - "If more than one carrier declares an ID, every duplicate site receives extract/duplicate-id and no ambiguous node is derived.", + intent: { + outcome: + "Give authors guardrails exactly where readiness and honesty checks depend on section content.", + }, + decision: { + context: "A fixed list of typed sections becomes stale when the readiness floor evolves.", + decision: + "Every section read by a floor clause has a closed typed shape; unsettled design and ui surfaces remain open.", + rationale: [ + "Closed shapes block authored-fact smuggling and provide useful authoring guidance without prematurely fixing unsettled surfaces.", + ], + consequences: [ + "A newly floor-read section becomes typed by the criterion, not by a frozen list.", ], - exampleSpace: { - given: [ - "a {firstCarrier:string} carrier declares {specId:string}", - "a {secondCarrier:string} carrier declares {specId:string}", - ], - when: ["the extraction root is read"], - [["t", "hen"].join("")]: [ - "both sites report {findingId:string}", - "no graph node is emitted for {specId:string}", - ], - }, }, }, - deliveryFacts: ["implemented", "has-verifier"], + deliveryFacts: [], }, { - id: "spec:validation.duplicate-ids.dual-carrier", - specKind: "example", - altitude: "story", - readiness: "ready", - file: "specs/validation/duplicate-ids.dual-carrier.sdp.md", - title: "TypeScript and Markdown duplicates are both refused", + id: "spec:decisions.kind-conditional-floor", + specKind: "decision", + altitude: "feature", + readiness: "defined", + file: "specs/decisions/kind-conditional-floor.sdp.md", + title: "Readiness evidence follows the Spec kind", narrative: null, sections: { - intent: { outcome: "Execute the duplicate-ID rule across both carrier surfaces." }, - behavior: { - examples: [ - { - given: [ - 'a {firstCarrier: "TypeScript"} carrier declares {specId: "spec:fixture.duplicate"}', - 'a {secondCarrier: "Markdown"} carrier declares {specId: "spec:fixture.duplicate"}', - ], - when: ["the extraction root is read"], - [["t", "hen"].join("")]: [ - 'both sites report {findingId: "extract/duplicate-id"}', - 'no graph node is emitted for {specId: "spec:fixture.duplicate"}', - ], - }, + intent: { + outcome: + "Make stated readiness structurally honest without turning the floor into a quota.", + }, + decision: { + context: + "Kinds have different natural evidence, while structural maturity clauses apply across every Spec.", + decision: + "The readiness floor combines cumulative kind-blind clauses with one kind-conditional evidence clause at each rung.", + rationale: [ + "Defined-only evidence and uniform evidence rules either leave padding legal or erase meaningful kind distinctions.", + ], + consequences: [ + "Floor rows are monotonic, promotion-neutral, and converge honestly where a kind has no stronger form.", ], }, }, - deliveryFacts: ["has-verifier"], + deliveryFacts: [], }, { - id: "spec:model.protocol-domain", - specKind: "model", + id: "spec:decisions.carried-evidence", + specKind: "decision", altitude: "feature", readiness: "defined", - file: "specs/model/protocol-domain.sdp.md", - title: "The Protocol domain uses one ratified language", + file: "specs/decisions/carried-evidence.sdp.md", + title: "Promoted evidence must carry its own evidence", narrative: null, sections: { - intent: { outcome: "Give self-hosting specs the same core vocabulary." }, - model: { - terms: { - Pack: "A grouping and review aggregate that states no system truth.", - Spec: "The one authored truth-primitive.", - anchor: "An in-code identity binding that states no intent.", - "delivery fact": "A machine-derived realization signal.", - }, + intent: { + outcome: + "Prevent empty promoted Specs and relation targets from satisfying an evidence floor.", + }, + decision: { + context: + "Promotion and constraints preserve meaning only when the promoted target carries the matching kind evidence.", + decision: + "Promoted evidence counts only when the promoted Spec holds its natural evidence; authoring-shape honesty rejects authored delivery facts and external `doc:` targets remain deferred.", + rationale: [ + "Counting empty children or wrong-kind constraints makes a structural floor pass without content, while readiness gates and premature external target types add the wrong contract.", + ], + consequences: [ + "The floor checks resolved target shape, and unresolved external decision links stay outside the current relation grammar.", + ], }, }, deliveryFacts: [], }, { - id: "spec:decisions.plain-language-references", + id: "spec:decisions.pack-reified", specKind: "decision", altitude: "feature", readiness: "defined", - file: "specs/decisions/plain-language-references.sdp.md", - title: "Durable references lead with meaning", + file: "specs/decisions/pack-reified.sdp.md", + title: "Packs group review context without becoming truth", narrative: null, sections: { intent: { - outcome: "Keep design rationale readable without decoding registries.", + outcome: + "Let related Specs be reviewed together without introducing another truth-bearing artifact.", }, decision: { - context: "Decision codes are useful lookup keys but poor standalone prose.", + context: "Delivery work needs a cross-cutting aggregate that is distinct from refinement.", decision: - "Durable references lead with plain-language meaning; decision codes follow parenthetically when useful.", - rationale: ["Meaning survives registry churn."], - consequences: ["AGENTS and plans lead with names."], + "A Pack declares membership and framing while stating no system truth; Specs may belong to many Packs.", + rationale: [ + "Treating a Pack as a truth primitive or a refinement parent confuses grouping with authored intent.", + ], + consequences: [ + "Review context remains disposable while Spec relations retain semantic hierarchy.", + ], }, }, deliveryFacts: [], }, { - id: "spec:decisions.concept-docs-dissolve", + id: "spec:decisions.agent-surface-scripts-graph", specKind: "decision", altitude: "feature", readiness: "defined", - file: "specs/decisions/concept-docs-dissolve.sdp.md", - title: "Concept documents may dissolve after executable truth lands", + file: "specs/decisions/agent-surface-scripts-graph.sdp.md", + title: "Agents script the visible graph", narrative: null, sections: { intent: { - outcome: "Keep intended truth authoritative while allowing exposition to shrink.", + outcome: + "Give agents composable graph context without a fixed command vocabulary becoming the model.", }, decision: { - context: "Concept documents currently carry both laws and unsettled representation.", + context: "Agents need decoded context and entry adapters without rebuilding graph joins.", decision: - "Concept documents may dissolve only after their semantic contract is carried by executable Specs and lean registries.", - rationale: ["Executable truth is easier to validate and consume."], - consequences: ["Deletion is later work, never part of phase 1."], + "The typed graph is the visible contract and agents script it directly through a thin reader surface.", + rationale: [ + "A verb wall duplicates graph semantics and hides composable data behind commands.", + ], + consequences: [ + "Entry adapters expose curated context while coverage gaps remain explicit rather than implied exhaustive.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:decisions.mcp-deferred", + specKind: "decision", + altitude: "feature", + readiness: "defined", + file: "specs/decisions/mcp-deferred.sdp.md", + title: "MCP integration remains deferred", + narrative: null, + sections: { + intent: { + outcome: + "Preserve a clean projection model without prematurely fixing an application integration surface.", + }, + decision: { + context: + "The graph already supports typed agent and human projections without an MCP transport.", + decision: + "MCP integration is deferred until a concrete caller establishes its boundary and contract.", + rationale: [ + "Adding an MCP surface without a caller invents verbs and persistence choices outside the projection model.", + ], + consequences: [ + "Consumers use the current graph and reader surfaces while MCP remains designed-in rather than claimed.", + ], }, }, deliveryFacts: [], @@ -330,43 +1502,149 @@ const expectedPackMembers = [ "spec:carrier.envelope-contract", "spec:carrier.markdown-parser", "spec:carrier.sdp-import", + "spec:carrier.sdp-import.round-trip", "spec:carrier.prose-ownership-rule", "spec:protocol.self-hosting", "spec:extraction.derive-graph", "spec:extraction.determinism", "spec:extraction.build-pipeline", + "spec:extraction.excludes", + "spec:extraction.claim-taxonomy", + "spec:extraction.regenerability", + "spec:extraction.schema-versioning", + "spec:extraction.executable-contracts", "spec:validation.readiness-floor", "spec:validation.duplicate-ids", + "spec:validation.two-check-families", + "spec:validation.referential-integrity", + "spec:validation.claim-separation", + "spec:validation.verification-linkage", + "spec:validation.pack-coherence", + "spec:validation.authored-honesty", + "spec:validation.warn-level-signals", + "spec:consumers.projections-model", + "spec:consumers.agent-surface", + "spec:consumers.design-review", + "spec:consumers.reader", + "spec:consumers.edit-model", "spec:model.protocol-domain", + "spec:model.core-model", + "spec:model.spec-sections", + "spec:model.relations", + "spec:model.stable-ids", + "spec:model.pack-aggregate", + "spec:model.anchors", "spec:validation.duplicate-ids.dual-carrier", "spec:decisions.plain-language-references", "spec:decisions.concept-docs-dissolve", + "spec:decisions.one-validation-path", + "spec:decisions.sdp-ts-extension", + "spec:decisions.point-per-example", + "spec:decisions.carrier-ruling", + "spec:decisions.prose-ownership", + "spec:decisions.envelope-grammar-posture", + "spec:decisions.exclusion-contract", + "spec:decisions.executable-meta-model", + "spec:decisions.adopt-the-nouns", + "spec:decisions.one-primitive", + "spec:decisions.protocol-naming", + "spec:decisions.binding-not-liveness", + "spec:decisions.content-only-sections", + "spec:decisions.typing-law", + "spec:decisions.kind-conditional-floor", + "spec:decisions.carried-evidence", + "spec:decisions.pack-reified", + "spec:decisions.agent-surface-scripts-graph", + "spec:decisions.mcp-deferred", ] as const; const expectedDeclaredRelations = [ ["spec:carrier.markdown-authoring", "dependsOn", "spec:carrier.markdown-parser"], + ["spec:carrier.markdown-authoring", "decidedBy", "spec:decisions.sdp-ts-extension"], + ["spec:carrier.markdown-authoring", "decidedBy", "spec:decisions.carrier-ruling"], ["spec:carrier.envelope-contract", "refines", "spec:carrier.markdown-authoring"], + ["spec:carrier.envelope-contract", "decidedBy", "spec:decisions.envelope-grammar-posture"], ["spec:carrier.markdown-parser", "refines", "spec:carrier.markdown-authoring"], ["spec:carrier.markdown-parser", "dependsOn", "spec:carrier.envelope-contract"], ["spec:carrier.sdp-import", "refines", "spec:carrier.markdown-authoring"], + ["spec:carrier.sdp-import.round-trip", "refines", "spec:carrier.sdp-import"], + ["spec:carrier.sdp-import.round-trip", "verifies", "spec:carrier.sdp-import"], ["spec:carrier.prose-ownership-rule", "refines", "spec:carrier.markdown-authoring"], + ["spec:carrier.prose-ownership-rule", "decidedBy", "spec:decisions.prose-ownership"], ["spec:protocol.self-hosting", "dependsOn", "spec:carrier.markdown-authoring"], ["spec:protocol.self-hosting", "dependsOn", "spec:model.protocol-domain"], ["spec:protocol.self-hosting", "decidedBy", "spec:decisions.concept-docs-dissolve"], + ["spec:protocol.self-hosting", "decidedBy", "spec:decisions.executable-meta-model"], + ["spec:protocol.self-hosting", "decidedBy", "spec:decisions.adopt-the-nouns"], + ["spec:protocol.self-hosting", "decidedBy", "spec:decisions.protocol-naming"], ["spec:extraction.derive-graph", "refines", "spec:protocol.self-hosting"], ["spec:extraction.derive-graph", "constrainedBy", "spec:extraction.determinism"], ["spec:extraction.determinism", "refines", "spec:protocol.self-hosting"], ["spec:extraction.build-pipeline", "refines", "spec:protocol.self-hosting"], ["spec:extraction.build-pipeline", "dependsOn", "spec:extraction.derive-graph"], + ["spec:extraction.excludes", "refines", "spec:extraction.derive-graph"], + ["spec:extraction.excludes", "decidedBy", "spec:decisions.exclusion-contract"], + ["spec:extraction.claim-taxonomy", "refines", "spec:extraction.derive-graph"], + ["spec:extraction.regenerability", "refines", "spec:extraction.determinism"], + ["spec:extraction.schema-versioning", "refines", "spec:extraction.derive-graph"], + ["spec:extraction.executable-contracts", "refines", "spec:extraction.build-pipeline"], ["spec:validation.readiness-floor", "refines", "spec:protocol.self-hosting"], ["spec:validation.readiness-floor", "dependsOn", "spec:model.protocol-domain"], + ["spec:validation.readiness-floor", "decidedBy", "spec:decisions.kind-conditional-floor"], + ["spec:validation.readiness-floor", "decidedBy", "spec:decisions.carried-evidence"], ["spec:validation.duplicate-ids", "refines", "spec:protocol.self-hosting"], ["spec:validation.duplicate-ids", "dependsOn", "spec:carrier.markdown-parser"], ["spec:validation.duplicate-ids.dual-carrier", "refines", "spec:validation.duplicate-ids"], ["spec:validation.duplicate-ids.dual-carrier", "verifies", "spec:validation.duplicate-ids"], + ["spec:validation.two-check-families", "refines", "spec:protocol.self-hosting"], + ["spec:validation.two-check-families", "decidedBy", "spec:decisions.one-validation-path"], + ["spec:validation.referential-integrity", "refines", "spec:validation.two-check-families"], + ["spec:validation.claim-separation", "refines", "spec:validation.two-check-families"], + ["spec:validation.verification-linkage", "refines", "spec:validation.two-check-families"], + ["spec:validation.pack-coherence", "refines", "spec:validation.two-check-families"], + ["spec:validation.authored-honesty", "refines", "spec:validation.two-check-families"], + ["spec:validation.warn-level-signals", "refines", "spec:validation.two-check-families"], + ["spec:consumers.projections-model", "refines", "spec:protocol.self-hosting"], + ["spec:consumers.projections-model", "decidedBy", "spec:decisions.mcp-deferred"], + ["spec:consumers.agent-surface", "refines", "spec:consumers.projections-model"], + ["spec:consumers.agent-surface", "decidedBy", "spec:decisions.agent-surface-scripts-graph"], + ["spec:consumers.design-review", "refines", "spec:consumers.projections-model"], + ["spec:consumers.reader", "refines", "spec:consumers.agent-surface"], + ["spec:consumers.edit-model", "refines", "spec:consumers.projections-model"], ["spec:model.protocol-domain", "refines", "spec:protocol.self-hosting"], + ["spec:model.core-model", "refines", "spec:protocol.self-hosting"], + ["spec:model.core-model", "decidedBy", "spec:decisions.one-primitive"], + ["spec:model.spec-sections", "refines", "spec:model.core-model"], + ["spec:model.spec-sections", "decidedBy", "spec:decisions.point-per-example"], + ["spec:model.spec-sections", "decidedBy", "spec:decisions.content-only-sections"], + ["spec:model.spec-sections", "decidedBy", "spec:decisions.typing-law"], + ["spec:model.relations", "refines", "spec:model.core-model"], + ["spec:model.stable-ids", "refines", "spec:model.core-model"], + ["spec:model.pack-aggregate", "refines", "spec:model.core-model"], + ["spec:model.pack-aggregate", "decidedBy", "spec:decisions.pack-reified"], + ["spec:model.anchors", "refines", "spec:model.core-model"], + ["spec:model.anchors", "decidedBy", "spec:decisions.binding-not-liveness"], ["spec:decisions.plain-language-references", "refines", "spec:protocol.self-hosting"], ["spec:decisions.concept-docs-dissolve", "refines", "spec:protocol.self-hosting"], + ["spec:decisions.one-validation-path", "refines", "spec:validation.two-check-families"], + ["spec:decisions.sdp-ts-extension", "refines", "spec:carrier.markdown-authoring"], + ["spec:decisions.point-per-example", "refines", "spec:model.spec-sections"], + ["spec:decisions.carrier-ruling", "refines", "spec:carrier.markdown-authoring"], + ["spec:decisions.prose-ownership", "refines", "spec:carrier.prose-ownership-rule"], + ["spec:decisions.envelope-grammar-posture", "refines", "spec:carrier.envelope-contract"], + ["spec:decisions.exclusion-contract", "refines", "spec:extraction.excludes"], + ["spec:decisions.executable-meta-model", "refines", "spec:protocol.self-hosting"], + ["spec:decisions.adopt-the-nouns", "refines", "spec:protocol.self-hosting"], + ["spec:decisions.one-primitive", "refines", "spec:model.core-model"], + ["spec:decisions.protocol-naming", "refines", "spec:protocol.self-hosting"], + ["spec:decisions.binding-not-liveness", "refines", "spec:model.anchors"], + ["spec:decisions.content-only-sections", "refines", "spec:model.spec-sections"], + ["spec:decisions.typing-law", "refines", "spec:model.spec-sections"], + ["spec:decisions.kind-conditional-floor", "refines", "spec:validation.readiness-floor"], + ["spec:decisions.carried-evidence", "refines", "spec:validation.readiness-floor"], + ["spec:decisions.pack-reified", "refines", "spec:model.pack-aggregate"], + ["spec:decisions.agent-surface-scripts-graph", "refines", "spec:consumers.agent-surface"], + ["spec:decisions.mcp-deferred", "refines", "spec:consumers.projections-model"], ] as const; const expectedWarnings = [] as const; @@ -522,6 +1800,216 @@ const expectedAnchors = [ constant: "dualCarrierDuplicateTestAnchor", site: "bindExample(", }, + { + id: "test:protocol.sdp-import.round-trip", + nodeType: "Anchor", + label: "TypeScript import round-trip contract preserves authored data", + type: "verifies", + target: "spec:carrier.sdp-import.round-trip", + file: "test/self-hosting-sdp-import.test.ts", + constant: "sdpImportRoundTripTestAnchor", + site: "bindExample(", + }, + { + id: "impl:protocol.anchor-extraction", + nodeType: "CodeNode", + label: "anchor-constant reification seam", + type: "satisfies", + target: "spec:model.anchors", + file: "src/extract/anchors.ts", + constant: "anchorExtractionAnchor", + site: "const ANCHOR_BUILDER_TARGET_FIELDS", + }, + { + id: "impl:protocol.anchor-model", + nodeType: "CodeNode", + label: "binding-only anchor model builders", + type: "satisfies", + target: "spec:model.anchors", + file: "src/model/anchors.ts", + constant: "anchorModelAnchor", + site: "export function specTest", + }, + { + id: "impl:protocol.pack-aggregate", + nodeType: "CodeNode", + label: "Pack aggregate and model references", + type: "satisfies", + target: "spec:model.pack-aggregate", + file: "src/model/pack.ts", + constant: "packAggregateAnchor", + site: "export function pack", + }, + { + id: "impl:protocol.spec-descriptors", + nodeType: "CodeNode", + label: "Spec kind, altitude, and readiness coordinates", + type: "satisfies", + target: "spec:model.core-model", + file: "src/model/descriptors.ts", + constant: "specDescriptorsAnchor", + site: "export const SPEC_KIND_DISPLAY_LABELS", + }, + { + id: "impl:protocol.spec-primitive", + nodeType: "CodeNode", + label: "Spec envelope and enrich-in-place shape", + type: "satisfies", + target: "spec:model.core-model", + file: "src/model/spec.ts", + constant: "specPrimitiveAnchor", + site: "export function spec", + }, + { + id: "impl:protocol.spec-relations", + nodeType: "CodeNode", + label: "declared Spec relation builders", + type: "satisfies", + target: "spec:model.relations", + file: "src/model/relations.ts", + constant: "specRelationsAnchor", + site: "export function supersedes", + }, + { + id: "impl:protocol.spec-sections", + nodeType: "CodeNode", + label: "typed Spec section shapes", + type: "satisfies", + target: "spec:model.spec-sections", + file: "src/model/sections.ts", + constant: "specSectionsAnchor", + site: "export interface SpecSections", + }, + { + id: "impl:protocol.stable-ids", + nodeType: "CodeNode", + label: "stable ID grammar parser", + type: "satisfies", + target: "spec:model.stable-ids", + file: "src/ids.ts", + constant: "stableIdsAnchor", + site: "export function parseId", + }, + { + id: "impl:protocol.verifier-semantics", + nodeType: "CodeNode", + label: "readiness clauses over direct verification bindings", + type: "satisfies", + target: "spec:model.spec-sections", + file: "src/validate/readiness-floor.ts", + constant: "verifierSemanticsAnchor", + site: "export function evaluateReadinessFloor", + }, + { + id: "impl:protocol.validation-families", + nodeType: "CodeNode", + label: "conformance and honesty validator registry", + type: "satisfies", + target: "spec:validation.two-check-families", + file: "src/validate/validators.ts", + constant: "validationFamiliesAnchor", + site: "export const graphValidatorIds", + }, + { + id: "impl:protocol.projections-model", + nodeType: "CodeNode", + label: "pure generated projection page contract", + type: "satisfies", + target: "spec:consumers.projections-model", + file: "src/projections/design-review.ts", + constant: "projectionModelAnchor", + site: "export interface DesignReviewPage", + }, + { + id: "impl:protocol.agent-surface", + nodeType: "CodeNode", + label: "typed graph reader and agent entry adapters", + type: "satisfies", + target: "spec:consumers.agent-surface", + file: "src/reader/reader.ts", + constant: "agentSurfaceAnchor", + site: "export function createReader", + }, + { + id: "impl:protocol.reader-impact", + nodeType: "CodeNode", + label: "file-level reader blast-radius contract", + type: "satisfies", + target: "spec:consumers.reader", + file: "src/reader/reader.ts", + constant: "readerImpactAnchor", + site: "export interface BlastRadius", + }, + { + id: "impl:protocol.reader", + nodeType: "CodeNode", + label: "thin typed graph reader construction", + type: "satisfies", + target: "spec:consumers.reader", + file: "src/reader/reader.ts", + constant: "readerAnchor", + site: "export function createReader", + }, + { + id: "impl:protocol.design-review", + nodeType: "CodeNode", + label: "renders the contextual Design Review projection", + type: "satisfies", + target: "spec:consumers.design-review", + file: "src/projections/design-review.ts", + constant: "designReviewAnchor", + site: "export function renderDesignReview", + }, + { + id: "impl:protocol.exclusion-surface", + nodeType: "CodeNode", + label: "strict root-relative exclusion input for both extraction surfaces", + type: "satisfies", + target: "spec:extraction.excludes", + file: "src/extract/index.ts", + constant: "exclusionSurfaceAnchor", + site: "export interface ExtractOptions", + }, + { + id: "impl:protocol.graph-claims", + nodeType: "CodeNode", + label: "declares the graph claim taxonomy", + type: "satisfies", + target: "spec:extraction.claim-taxonomy", + file: "src/graph/schema.ts", + constant: "graphClaimsAnchor", + site: "export const graphClaims", + }, + { + id: "impl:protocol.regenerability", + nodeType: "CodeNode", + label: "repeats graph and contract producers for deterministic regeneration", + type: "satisfies", + target: "spec:extraction.regenerability", + file: "src/cli/build-command.ts", + constant: "regenerabilityAnchor", + site: "export function runBuild", + }, + { + id: "impl:protocol.schema-version", + nodeType: "CodeNode", + label: "declares the graph schema version", + type: "satisfies", + target: "spec:extraction.schema-versioning", + file: "src/graph/schema.ts", + constant: "schemaVersionAnchor", + site: "export const schemaVersion", + }, + { + id: "impl:protocol.executable-contracts", + nodeType: "CodeNode", + label: "derives step and example-space contracts from the graph", + type: "satisfies", + target: "spec:extraction.executable-contracts", + file: "src/codegen/contracts.ts", + constant: "executableContractsAnchor", + site: "export function generateContracts", + }, ] as const; function lineContaining(source: string, token: string): number { @@ -530,10 +2018,13 @@ function lineContaining(source: string, token: string): number { return line + 1; } -describe("the self-hosting phase-1 carrier corpus", () => { - it("derives the fifteen Markdown-canonical specs and their exact Pack checkpoint from the root", () => { +describe("the self-hosting corpus", () => { + it("derives the Markdown-canonical specs and their exact Pack checkpoint from the root", () => { // Given: the repository root with evidence and the worked example excluded from the authored model. - const result = extract({ root: repoRoot, exclude: ["explorations", "examples"] }); + const result = extract({ + root: repoRoot, + exclude: ["explorations", "examples", "test/fixtures/import/parity"], + }); // When: the root corpus is reified through the public extractor. const nodeIds = result.graph.nodes.map((node) => node.id).sort(); @@ -550,7 +2041,7 @@ describe("the self-hosting phase-1 carrier corpus", () => { subjectId, })), ).toEqual(expectedWarnings); - expect(result.counts).toEqual({ specs: 15, packs: 1, anchors: 15 }); + expect(result.counts).toEqual({ specs: 58, packs: 1, anchors: 36 }); expect(nodeIds).toEqual( [ "pack:self-hosting-v1", @@ -559,20 +2050,63 @@ describe("the self-hosting phase-1 carrier corpus", () => { "spec:carrier.markdown-parser", "spec:carrier.prose-ownership-rule", "spec:carrier.sdp-import", + "spec:carrier.sdp-import.round-trip", + "spec:consumers.agent-surface", + "spec:consumers.design-review", + "spec:consumers.edit-model", + "spec:consumers.projections-model", + "spec:consumers.reader", "spec:decisions.concept-docs-dissolve", + "spec:decisions.one-validation-path", + "spec:decisions.sdp-ts-extension", + "spec:decisions.point-per-example", + "spec:decisions.carrier-ruling", + "spec:decisions.prose-ownership", + "spec:decisions.envelope-grammar-posture", + "spec:decisions.exclusion-contract", + "spec:decisions.executable-meta-model", + "spec:decisions.adopt-the-nouns", + "spec:decisions.one-primitive", + "spec:decisions.protocol-naming", + "spec:decisions.binding-not-liveness", + "spec:decisions.content-only-sections", + "spec:decisions.typing-law", + "spec:decisions.kind-conditional-floor", + "spec:decisions.carried-evidence", + "spec:decisions.pack-reified", + "spec:decisions.agent-surface-scripts-graph", + "spec:decisions.mcp-deferred", "spec:decisions.plain-language-references", "spec:extraction.build-pipeline", + "spec:extraction.claim-taxonomy", "spec:extraction.derive-graph", "spec:extraction.determinism", + "spec:extraction.excludes", + "spec:extraction.executable-contracts", + "spec:extraction.regenerability", + "spec:extraction.schema-versioning", + "spec:model.anchors", + "spec:model.core-model", + "spec:model.pack-aggregate", "spec:model.protocol-domain", + "spec:model.relations", + "spec:model.spec-sections", + "spec:model.stable-ids", "spec:protocol.self-hosting", "spec:validation.duplicate-ids", "spec:validation.duplicate-ids.dual-carrier", + "spec:validation.authored-honesty", + "spec:validation.claim-separation", + "spec:validation.pack-coherence", "spec:validation.readiness-floor", + "spec:validation.referential-integrity", + "spec:validation.two-check-families", + "spec:validation.verification-linkage", + "spec:validation.warn-level-signals", ...expectedAnchors.map((anchor) => anchor.id), ].sort(), ); - expect(result.graph.nodes).toHaveLength(31); + expect(result.graph.nodes).toHaveLength(95); expect( primitiveNodes .map((node) => ({ @@ -616,7 +2150,7 @@ describe("the self-hosting phase-1 carrier corpus", () => { }), {}, ), - ).toEqual({ idea: 1, defined: 9, ready: 5 }); + ).toEqual({ defined: 51, ready: 7 }); expect( result.graph.edges .filter((edge) => edge.type === "belongsTo") @@ -626,12 +2160,12 @@ describe("the self-hosting phase-1 carrier corpus", () => { id: "pack:self-hosting-v1", nodeType: "Pack", claim: "declared", - title: "Self-hosting phase 1", - framing: "The Protocol authors and validates its own phase-1 delivery model.", - modelRefs: ["spec:model.protocol-domain"], + title: "Self-hosting", + framing: "The Protocol authors and validates its own delivery model.", + modelRefs: ["spec:model.protocol-domain", "spec:model.core-model"], file: "specs/self-hosting.pack.sdp.ts", }); - expect(result.graph.edges).toHaveLength(53); + expect(result.graph.edges).toHaveLength(180); expect( result.graph.edges .filter((edge) => edge.claim === "anchored") @@ -726,5 +2260,28 @@ describe("the self-hosting phase-1 carrier corpus", () => { claim: "anchored", }, ]); + + const importChildId = "spec:carrier.sdp-import.round-trip"; + const importParentId = "spec:carrier.sdp-import"; + const importChild = primitiveNodes.find((node) => node.id === importChildId); + const importParent = primitiveNodes.find((node) => node.id === importParentId); + + expect(importChild?.deliveryFacts).toEqual(["has-verifier"]); + expect(importParent?.deliveryFacts).toEqual(["has-verifier"]); + expect( + result.graph.edges.filter( + (edge) => + edge.from === "test:protocol.sdp-import.round-trip" && + edge.type === "verifies" && + edge.claim === "anchored", + ), + ).toEqual([ + { + from: "test:protocol.sdp-import.round-trip", + type: "verifies", + to: importChildId, + claim: "anchored", + }, + ]); }); }); diff --git a/test/self-hosting-sdp-import.test.ts b/test/self-hosting-sdp-import.test.ts new file mode 100644 index 0000000..c6b0315 --- /dev/null +++ b/test/self-hosting-sdp-import.test.ts @@ -0,0 +1,61 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { describe, expect } from "vitest"; + +import { ref, specTest, testAnchorId } from "@libar-dev/software-delivery-protocol"; +import { bindExample } from "@libar-dev/software-delivery-protocol/vitest"; + +import { roundTripContract } from "../generated/contracts/carrier.sdp-import.round-trip.contract.js"; +import { importTypeScriptSpec } from "../src/index.js"; +import type { ImportResult } from "../src/import/import.js"; +import { assertAuthoredRoundTrip } from "./import-round-trip.helpers.js"; + +interface ImportRoundTripWorld { + readonly source: string; + readonly relativePath: string; + result: ImportResult | undefined; +} + +function importResult(world: ImportRoundTripWorld): ImportResult { + if (world.result === undefined) { + throw new Error("The import step must run before the round-trip outcome is asserted."); + } + + return world.result; +} + +const sdpImportRoundTripTestAnchor = specTest({ + id: testAnchorId("test:protocol.sdp-import.round-trip"), + label: "TypeScript import round-trip contract preserves authored data", + verifies: ref("spec:carrier.sdp-import.round-trip"), +}); +void sdpImportRoundTripTestAnchor; + +describe("TypeScript import round-trip contract", () => { + bindExample( + roundTripContract, + (): ImportRoundTripWorld => ({ + source: readFileSync( + fileURLToPath(new URL("./fixtures/import/round-trip/behavior.sdp.ts.txt", import.meta.url)), + "utf8", + ), + relativePath: "behavior.sdp.ts", + result: undefined, + }), + { + "a TS-carrier spec": (world) => { + expect(world.relativePath).toBe("behavior.sdp.ts"); + }, + "importTypeScriptSpec runs": (world) => { + world.result = importTypeScriptSpec(world.source, world.relativePath); + }, + "the emitted Markdown re-parses to an equal graph": (world) => { + const result = importResult(world); + expect(result.findings).toEqual([]); + expect(result.emitted).toBeDefined(); + assertAuthoredRoundTrip(world.source, world.relativePath); + }, + }, + ); +}); diff --git a/tsconfig.json b/tsconfig.json index 3b01981..077a2bf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,5 @@ } }, "include": ["src/**/*.ts", "test/**/*.ts", "tsup.config.ts", "vitest.config.ts"], - "exclude": ["dist", "generated", "examples"] + "exclude": ["dist", "generated", "examples", "test/fixtures/import/parity"] } diff --git a/tsup.config.ts b/tsup.config.ts index 86b5079..7a90897 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -3,13 +3,25 @@ import { defineConfig } from "tsup"; // The CLI entry (`src/cli/sdp.ts`) carries its own source shebang; esbuild preserves a leading // shebang on an entry point, so `dist/cli/sdp.js` stays executable while `dist/index.js` (the // library entry, no shebang) stays clean. No banner / strip-plugin / post-build normalisation. -export default defineConfig({ - entry: ["src/index.ts", "src/cli/sdp.ts", "src/runner/index.ts", "src/adapters/vitest.ts"], - format: ["esm"], - dts: true, - platform: "node", - target: "es2022", - clean: true, - splitting: false, - outDir: "dist", -}); +export default defineConfig([ + { + entry: ["src/index.ts", "src/cli/sdp.ts", "src/runner/index.ts", "src/adapters/vitest.ts"], + format: ["esm"], + dts: true, + platform: "node", + target: "es2022", + clean: true, + splitting: false, + outDir: "dist", + }, + { + entry: ["src/index.ts"], + format: ["cjs"], + dts: false, + platform: "node", + target: "es2022", + clean: false, + splitting: false, + outDir: "dist", + }, +]);