From aa0eb4f349311d7d1f8554b2a85c940ffe341397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Tue, 3 Nov 2026 18:45:32 +0100 Subject: [PATCH 01/16] evidence: open the arc that executes the whole-repository review Owner working rules: execute the confirmed findings of the whole-repository review (PR #38) with the same approach as the review: workflows of Claude and GPT agents (GPT through the relay plugin), each lane implemented by one model family and reviewed by the other, while the coordinating session keeps its context for coordination and execution. Commits, push and a draft pull request are authorized; the owner reviews and merges. Base: main b5e0a8be (PR #36), the commit the review read. Arc Pack: none. The arc executes PR #38's findings; record findings wait for the delivery-record cleanup, and owner questions are authored, not answered. Add the arc's plan: six lanes with their finding ids, implementing and reviewing families, and what stays out of scope. The directory is removed before merge. Co-Authored-By: Claude Opus 5.5 --- evidence/pr39/README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 evidence/pr39/README.md diff --git a/evidence/pr39/README.md b/evidence/pr39/README.md new file mode 100644 index 000000000..0a127ebbb --- /dev/null +++ b/evidence/pr39/README.md @@ -0,0 +1,30 @@ +# Executing the whole-repository review + +This arc carries out the confirmed findings of the whole-repository review in PR #38, at `main` +`b5e0a8bea1e3bedd48c616f96c9928e955792753`. Each finding there was verified by a second model +family. This directory is arc evidence and is removed before merge. + +## Lanes + +Each lane runs on its own branch and worktree. One model family implements it; the other reviews +the diff adversarially. A fix proves itself with a test that fails without it. After review, the +lane is merged into this branch, and the full gate runs once over the merged result. + +| Lane | Findings | Implements | Reviews | +| ------------------------ | ------------------------------------------------------------------------ | ---------- | ------- | +| C corpus | The seven-change proposal; six owner questions; three capture candidates | Claude | GPT | +| W writes inside the root | L4-01 (high), L4-04, L4-02 | GPT | Claude | +| H1 honesty and carriers | L3-01 (high), L1-03, L1-04, L1-05 | GPT | Claude | +| H2 verification runtime | L1-01, L1-02, L5-01, L5-02 | GPT | Claude | +| P CLI and package polish | L2-3, L2-5, L3-03, L4-03, L9-F2, C1 | Claude | GPT | +| D docs and vocabulary | L6-1, L6-4, L6-6, L2-6, L9-F1, L9-F4, L9-F5 | Claude | GPT | + +## Out of scope + +- **The inventory's record findings** wait for the owner's review and the delivery-record + cleanup. Nothing in `plans/` or `reviews/` is deleted here. +- **Owner questions are authored, not answered.** The symbolic-link discovery policy (L3-02) + stays open, so its code change waits. +- **The concept-document drift** (L6-2, D1, L6-5) belongs to the cleanup, which dissolves those + documents. +- **The article corrections** are in PR #34. From 8503030803aa3c0adbcf4d06588839dba3eb3565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Tue, 3 Nov 2026 19:06:46 +0100 Subject: [PATCH 02/16] docs: correct the handbook, registry, recipe catalog, skill and walkthrough against the graph L6-1: AGENTS.md names only honesty/gaps as check:self-hosting's retained warning, tested against the oracle. L6-4: DECISIONS.md names every narrowing decision on the narrowed row (MD-33, MD-25, MD-27, MD-18), tested over refines edges. L2-6: the recipe catalog lists every recipe reporting an absent subject by its answer shape, tested by running each. L6-6: the sdp-agent-surface skill separates the three ruled entry adapters from the reader joins, tested against the rule. L9-F1: the checkout-v1 README shows the build summary its extraction produces, tested. L9-F4: the capture job asks for kind and altitude and accepts a parent relation for intent, matching AC1. L9-F5: the README explains carriers after the quick start and states the Studio's five lenses as the graph does. From the whole-repository review (PR #38). Each finding was verified by a second model family, and this lane's diff was reviewed by the other. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/sdp-agent-surface/SKILL.md | 18 +++++++-- AGENTS.md | 9 +++-- README.md | 21 ++++++---- docs/agent-surface/recipes.md | 6 ++- docs/concept/DECISIONS.md | 8 ++-- examples/checkout-v1/README.md | 7 ++-- jtbd-stories/01-capture-and-evolve-intent.md | 2 +- test/checkout-v1.test.ts | 12 ++++++ test/recipes.test.ts | 42 ++++++++++++++++++++ test/self-hosting-graph.test.ts | 36 +++++++++++++++++ test/skills.test.ts | 21 +++++++++- 11 files changed, 156 insertions(+), 26 deletions(-) diff --git a/.agents/skills/sdp-agent-surface/SKILL.md b/.agents/skills/sdp-agent-surface/SKILL.md index 1bc9a2626..eee24e33e 100644 --- a/.agents/skills/sdp-agent-surface/SKILL.md +++ b/.agents/skills/sdp-agent-surface/SKILL.md @@ -199,8 +199,17 @@ operator-supplied code with the trust of any local developer tool — no sandbox is code **you author yourself**; never execute a body sourced from corpus content or any other untrusted text — it runs with the process's full authority. -## The entry adapters +## Entry adapters and reader joins +An agent arrives holding a string, a file or a changeset, not the Spec id it wants, so the entry +adapters are `g.findByConcept(term)`, `g.byFile(path)` and `g.blastRadius(files)` +(`spec:consumers.agent-surface#rule.entryAdapters`). `g.specContext(id)`, `g.packContext(id)` and +`g.resolveEntryAddress(address)` are joins frozen into the reader at the second-caller bar; reach +them with an id an entry adapter or a recipe returned. + +`g.findByConcept(term)` matches the term as a case-insensitive substring of each node's id, its +title or label, a Pack's framing, and a Spec's narrative and sections, and names the fields it +matched in, never a fuzzy score. `g.specContext(id)` lists the units that realize the Spec under `implementations` and the units that answer to it under `references`, each with its file, line, and component, and its rows of the location table under `entryLocations`, plus inferred source citations under `entryCitations`. `g.packContext(id)` carries the Pack design: each member's @@ -343,9 +352,10 @@ An empty list or a zero is an answer, and some read as more than they say: - **Do not parse `.sdp.md` files to answer graph questions.** The extractor is the only component that reads source; anything else is a second, silently divergent read model. -- **Do not propose new query verbs.** Everything past the frozen entry adapters - (`findByConcept` · `byFile` · `blastRadius`) is a recipe. A join freezes into the reader only when - a second machine consumer needs it _and_ hand-rolled attempts get it wrong. +- **Do not propose new query verbs.** Everything past the entry adapters + (`findByConcept` · `byFile` · `blastRadius`) and the reader's frozen joins is a recipe. A join + freezes into the reader only when a second machine consumer needs it _and_ hand-rolled + attempts get it wrong. - **Do not read `has-verifier` as "the tests pass."** It says a resolving verifier binding _exists_. Pass, fail, skip, and quarantine are CI's. - **Do not read `implemented` as "it is live."** It says a code anchor binds to the Spec. Runtime diff --git a/AGENTS.md b/AGENTS.md index 1af0c26dd..62861b877 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -293,7 +293,8 @@ self-hosting/example checks → preflight correctly, so prefer it after engine e The `check:example` step reports 0 errors and 0 warnings. The unbound `defined` example stays visible in the Design Review verifier bindings and recipe 10, declared versus enabled verifiers. -The `check:self-hosting` step emits two kinds of intentional warning: `honesty/gaps`, one per -owner-ratified ready Spec without a resolving verifier binding, and `conformance/prose-mentions`, -one per mentioning Spec and target pair with no declared relation either way (recipe 22 lists the -pairs). The self-hosting oracle pins both sets; they are expected, not a regression. +The `check:self-hosting` step emits one kind of intentional warning: `honesty/gaps`, one per +owner-ratified ready Spec, decisions aside, without a resolving verifier binding. The self-hosting +oracle pins that set and pins that no prose mention lacks a declared relation (recipe 22's +`unbacked` stays empty), so a `conformance/prose-mentions` warning, like any other new warning, is +a regression. diff --git a/README.md b/README.md index 5514d6bf8..f82008b1f 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,8 @@ Libar Software Delivery Protocol is a typed, executable, self-validating meta-mo delivery: author `Spec` documents in the repository, derive one graph, and check conformance and honesty. -**Carrier rule:** Specs and Packs default to Markdown; the TS DSL survives as import source and a -lawful per-ID option, while behavior and example Specs may use Gherkin canonically per ID (the -carrier ruling, MD-18, the Pack syntax ruling, MD-25, and the Gherkin carrier option, MD-27). +You write Specs as Markdown files (`*.sdp.md`) and group them into Packs. `sdp` derives one graph +from those files and from anchors in your code, then checks it. Read the [concept](docs/concept/README.md), the [ubiquitous language](CONTEXT.md), and the [checkout-v1 walkthrough](examples/checkout-v1/README.md). @@ -57,6 +56,13 @@ fails with `xcode-select: error: tool 'sdp' requires Xcode`. The checked-in pnpm disables pnpm 11's dependency auto-reconciliation for repository scripts, so the supported `pnpm sdp` / `pnpm sdp:q` forms do not rewrite an npm-installed dependency tree. +## Carriers + +A carrier is the file that holds a Spec or a Pack. Specs and Packs default to Markdown; the +TypeScript DSL survives as import source and a lawful per-ID option, and behavior and example Specs +may use Gherkin canonically per ID (the carrier ruling, MD-18, the Pack syntax ruling, MD-25, and +the Gherkin carrier option, MD-27). + ## Installed CLI (adopter repositories) Pin the Protocol to a full commit SHA in your adopter's `package.json`: @@ -145,10 +151,11 @@ diagrams, `gherkin` the Gherkin-shaped read view, and `studio` the static Spec S JavaScript query body against a freshly derived graph. Run `sdp --help` for the complete option contract. Adopters own their root and exclusion policy. -The Studio's phase 1 adds static SVG diagrams, the architecture and tests lenses, all Spec-page -panels, registers, the owner queue and a glossary. A base graph adds a design diff; CTRF reports -add external run evidence. The composing panel hands off scoped proposals, every Pack downloads -as one HTML file, and pages print as documents. Open `generated/spec-studio/index.html` through +The Studio is a static page set: five lenses (Packs, capabilities, architecture, tests and +evidence), static SVG diagrams, every Spec-page panel, registers, the owner queue, the roadmap and a +glossary. A base graph adds a design diff; CTRF reports add external run evidence. The composing +panel hands off scoped proposals, every Pack downloads as one HTML file, and pages print as +documents. Open `generated/spec-studio/index.html` through `file://` or a static host. `studio --watch` rebuilds on carrier changes, with no server; source and input edits start no cycle. Copy a base graph outside `generated/` before using `--base`. Run `stamp` after building to record publishing metadata. The reusable diff --git a/docs/agent-surface/recipes.md b/docs/agent-surface/recipes.md index 4058274b1..0954445e1 100644 --- a/docs/agent-surface/recipes.md +++ b/docs/agent-surface/recipes.md @@ -117,8 +117,10 @@ reason and expected input. Missing required input is never answered as empty, ex `files`, which reads as an empty change list. The samples name this repository's corpus, so every body runs as written here. The recipe check executes each one unchanged. Recipes report absent subjects in their documented result shape. -Most use `found: false`; the design diff, context bundle, perspective filter and key-family register report an absent -Pack in `diagnostics`. A value that is not JSON, JSON that is not an object (null, an array, a +Recipes 3, 5, 9, 14, 19, 21, 27, 29, 34 and 39 answer an absent subject with `found: false`. The +design diff, context bundle, perspective filter, key-family register, example-space coverage, +rule-key proposal, entry citations, roadmap, design slice and roadmap memo (recipes 35, 36, 37, +41, 44, 48, 51, 53, 54 and 55) report an absent Pack in `diagnostics`. A value that is not JSON, JSON that is not an object (null, an array, a string, a number or a boolean), or a file that cannot be read is refused before the body runs, with exit 1 and one line on stderr. diff --git a/docs/concept/DECISIONS.md b/docs/concept/DECISIONS.md index 222b9c8dd..08345962d 100644 --- a/docs/concept/DECISIONS.md +++ b/docs/concept/DECISIONS.md @@ -26,22 +26,22 @@ positions and are never reused. | MD-15 | the `.sdp.ts` extension | durable | The extension law is re-pointed, not repealed: carrier extensions identify Specs without test-glob collisions. MD-28 applies the same law to the Gherkin option. | [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 and Packs default to Markdown; the TS DSL survives as import source and a lawful per-ID option. MD-25 completes the Pack syntax deferral; MD-27 adds a Gherkin option for behavior and example Specs; MD-28 settles that option's canonical suffix as `.sdp.gherkin`. The surviving law is one canonical surface per ID, no mixing. | [Spec](../../specs/decisions/carrier-ruling.sdp.md) (`spec:decisions.carrier-ruling`) | +| MD-18 | the carrier ruling | durable | Specs and Packs default to Markdown; the TS DSL survives as import source and a lawful per-ID option. MD-25 completes the Pack syntax deferral; MD-27 adds a Gherkin option for behavior and example Specs; MD-28 settles that option's canonical suffix as `.sdp.gherkin`. The surviving law is one canonical surface per ID, no mixing. | [Spec](../../specs/decisions/carrier-ruling.sdp.md) (`spec:decisions.carrier-ruling`); narrowed by [Spec](../../specs/decisions/pack-markdown-carrier.sdp.md) (`spec:decisions.pack-markdown-carrier`) and [Spec](../../specs/decisions/gherkin-carrier-option.sdp.md) (`spec:decisions.gherkin-carrier-option`) | | 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`) | | MD-22 | the agent front door | durable | The CLI's one evaluation sink and the exported reader are two entrances over one seam, deriving the graph in process on every invocation. | [Spec](../../specs/decisions/agent-front-door.sdp.md) (`spec:decisions.agent-front-door`) | | MD-23 | verification posture, not realization | durable | `verification.mode` states intended posture; enabled-verifier realization remains derived. | [Spec](../../specs/decisions/verification-posture-not-realization.sdp.md) (`spec:decisions.verification-posture-not-realization`) | | MD-24 | the example realization posture | durable | Examples normally carry verification evidence rather than build-backlog work; implementation remains a direct binding-derived fact. | [Spec](../../specs/decisions/example-realization-posture.sdp.md) (`spec:decisions.example-realization-posture`) | -| MD-25 | the Pack syntax ruling | durable | Packs gain a Markdown manifest carrier closed to id · specs · modelRefs with H1 title and prose framing; the TS manifest stays a lawful per-ID option. | [Spec](../../specs/decisions/pack-markdown-carrier.sdp.md) (`spec:decisions.pack-markdown-carrier`) | +| MD-25 | the Pack syntax ruling | durable | Packs gain a Markdown manifest carrier closed to id · specs · modelRefs with H1 title and prose framing; the TS manifest stays a lawful per-ID option. | [Spec](../../specs/decisions/pack-markdown-carrier.sdp.md) (`spec:decisions.pack-markdown-carrier`); narrowed by [Spec](../../specs/decisions/roadmap-priority.sdp.md) (`spec:decisions.roadmap-priority`) | | MD-26 | the decision readiness posture | durable | Registry-ratified decision records state `ready`; operational backlog and verifier-gap signals exclude kind `decision` while preserving an explicit count. | [Spec](../../specs/decisions/decision-readiness-posture.sdp.md) (`spec:decisions.decision-readiness-posture`) | -| MD-27 | the Gherkin carrier option | durable | Behavior and example Spec IDs may select a graph-aware Gherkin carrier; Markdown stays default, execution stays behind generated contracts and anchored handlers, and no lifecycle taxonomy is imported. MD-28 settles the canonical suffix as `.sdp.gherkin`. | [Spec](../../specs/decisions/gherkin-carrier-option.sdp.md) (`spec:decisions.gherkin-carrier-option`) | +| MD-27 | the Gherkin carrier option | durable | Behavior and example Spec IDs may select a graph-aware Gherkin carrier; Markdown stays default, execution stays behind generated contracts and anchored handlers, and no lifecycle taxonomy is imported. MD-28 settles the canonical suffix as `.sdp.gherkin`. | [Spec](../../specs/decisions/gherkin-carrier-option.sdp.md) (`spec:decisions.gherkin-carrier-option`); narrowed by [Spec](../../specs/decisions/sdp-gherkin-extension.sdp.md) (`spec:decisions.sdp-gherkin-extension`) and [Spec](../../specs/decisions/carrier-universality.sdp.md) (`spec:decisions.carrier-universality`) | | MD-28 | the `.sdp.gherkin` extension | durable | Canonical Gherkin carriers use `.sdp.gherkin`; bare `.feature` stays non-canonical import-source territory so Protocol files stay outside default Cucumber globs. | [Spec](../../specs/decisions/sdp-gherkin-extension.sdp.md) (`spec:decisions.sdp-gherkin-extension`) | | MD-29 | the carrier universality bound | durable | Gherkin remains honest for behavior and example Specs; universality is per-ID carriers plus a generated read projection, with Markdown still the default. | [Spec](../../specs/decisions/carrier-universality.sdp.md) (`spec:decisions.carrier-universality`) | | MD-30 | structural anchors confer nothing | deleted | `component` and `uses` author narrow CodeNode structure while leaving realization, readiness, and delivery facts untouched; replaced by MD-38, which restates what survives. | deleted; its text reads in git at `90c75dd` (`specs/decisions/structural-anchor-semantics.sdp.md`) | | MD-31 | adopted registrars are committed | durable | A registrar imported by tracked authored code is committed and byte-checked; unadopted registrar siblings remain ignored and regenerable. | [Spec](../../specs/decisions/adopted-registrars-committed.sdp.md) (`spec:decisions.adopted-registrars-committed`) | | MD-32 | the shipped projections stay frozen | durable | The shipped Design Review, census, Mermaid, and Gherkin projections are settled read models; re-specifying them is refused; a decision that supersedes it replaces it whole, and a decision that refines it narrows it for one projection or rendering, each passing the ADR test. Each record that refines it carves out the renderings its relation's reason names: open-section entries render as a list, an open question shows its key, the Pack page presents the Pack's design (MD-39), the Design Review lists referencing units and the census its References table (MD-37), and the census renders role, layer and context tables beside the Design Review's unit attributes (MD-38). | [Spec](../../specs/decisions/shipped-projections-frozen.sdp.md) (`spec:decisions.shipped-projections-frozen`); narrowed by [Spec](../../specs/decisions/authored-entry-order.sdp.md) (`spec:decisions.authored-entry-order`), [Spec](../../specs/decisions/question-key-rendering.sdp.md) (`spec:decisions.question-key-rendering`), [Spec](../../specs/decisions/pack-design-page.sdp.md) (`spec:decisions.pack-design-page`), [Spec](../../specs/decisions/anchor-binding-grain.sdp.md) (`spec:decisions.anchor-binding-grain`), and [Spec](../../specs/decisions/architectural-annotation.sdp.md) (`spec:decisions.architectural-annotation`) | -| MD-33 | planning truths live in ruled graph homes | durable | Each planning-truth type has one ruled home in the graph, the briefs index shape is retired as a carrier of law, and a refusal is replaced whole by a decision that supersedes it or narrowed by one that refines it, either passing the ADR test; a decision refines the Spec whose question it settles, which names it through `decidedBy`. | [Spec](../../specs/decisions/planning-truths-placement.sdp.md) (`spec:decisions.planning-truths-placement`) | +| MD-33 | planning truths live in ruled graph homes | durable | Each planning-truth type has one ruled home in the graph, the briefs index shape is retired as a carrier of law, and a refusal is replaced whole by a decision that supersedes it or narrowed by one that refines it, either passing the ADR test; a decision refines the Spec whose question it settles, which names it through `decidedBy`. | [Spec](../../specs/decisions/planning-truths-placement.sdp.md) (`spec:decisions.planning-truths-placement`); narrowed by [Spec](../../specs/decisions/roadmap-priority.sdp.md) (`spec:decisions.roadmap-priority`) and [Spec](../../specs/decisions/roadmap-over-plans.sdp.md) (`spec:decisions.roadmap-over-plans`) | | MD-34 | architectural significance rides existing primitives | deleted | Specs carrying architectural significance are linked by the existing relations and no pattern vocabulary is admitted; replaced by MD-38, which restates what survives and returns the CodeNode-grain role. | deleted; its text reads in git at `90c75dd` (`specs/decisions/architectural-significance-rides-primitives.sdp.md`) | | MD-35 | source commentary never enters the graph | deleted | Comment prose authors no graph content and law-carrying comments promote into Specs; replaced by MD-36, which restates that half and reverses the identity half. | deleted; its text reads in git at `90c75dd` (`specs/decisions/jsdoc-graph-extraction-refused.sdp.md`) | | MD-36 | the comment form | durable | A `/** … */` comment carrying reserved camelCase `@sdp*` tags is a second extracted representation of the anchor, feeding the same closed envelope as the constant form; prose still authors nothing, no import is required, and the package ships a zero-dependency `/anchors` subpath. | [Spec](../../specs/decisions/anchor-comment-form.sdp.md) (`spec:decisions.anchor-comment-form`) | diff --git a/examples/checkout-v1/README.md b/examples/checkout-v1/README.md index 82fa9c1cf..ed6248532 100644 --- a/examples/checkout-v1/README.md +++ b/examples/checkout-v1/README.md @@ -1,7 +1,8 @@ # checkout-v1 — the worked example -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 +The MVP bounded context: **Order Management**, modeled as `pack:checkout-v1` — eleven `Spec`s and +five anchors, with a second `Pack`, the worked roadmap `pack:checkout-roadmap`. It exists to prove +the loop end-to-end on one small, honest slice: author 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 @@ -59,7 +60,7 @@ npm run generate:example # publish all five project their `claim`, `declared`, `anchored`, or `inferred`, without collapsing them: ``` -11 specs · 1 packs · 5 anchors → 17 nodes · 32 edges (0 errors, 0 warnings) +11 specs · 2 packs · 5 anchors → 18 nodes · 34 edges (0 errors, 0 warnings) ``` Beside the graph it writes three modules in `generated/contracts/`: a step contract for each diff --git a/jtbd-stories/01-capture-and-evolve-intent.md b/jtbd-stories/01-capture-and-evolve-intent.md index 3b136593f..9948df076 100644 --- a/jtbd-stories/01-capture-and-evolve-intent.md +++ b/jtbd-stories/01-capture-and-evolve-intent.md @@ -10,7 +10,7 @@ The job here is to get a thought into the system and let it grow without ever fo **Phase:** MVP **References:** [the core-model spec](../specs/model/core-model.sdp.md) (`spec:model.core-model`) -> **When** a half-formed idea for a behaviour or capability shows up mid-work, **I want to** record it as a spec in seconds with only an `id`, a `title`, and a sentence of intent, **so I can** keep the thought in the canonical repo instead of losing it to a side channel. +> **When** a half-formed idea for a behaviour or capability shows up mid-work, **I want to** record it as a spec in seconds with only an `id`, a `title`, its `kind` and `altitude`, and a sentence of intent (or a parent relation), **so I can** keep the thought in the canonical repo instead of losing it to a side channel. **Essence:** The cheapest possible on-ramp to the one graph. If capture has friction, intent leaks out of the repo. diff --git a/test/checkout-v1.test.ts b/test/checkout-v1.test.ts index 41a94e5c1..5fa805c4d 100644 --- a/test/checkout-v1.test.ts +++ b/test/checkout-v1.test.ts @@ -35,6 +35,18 @@ describe("checkout-v1 tracer bullet (extractor-fed)", () => { expect(roadmap).not.toContain("Order placement needs design"); }); + // A newcomer's first step compares this line with what `build` prints. + it("documents the build summary the example's extraction produces", () => { + const readme = readFileSync( + new URL("../examples/checkout-v1/README.md", import.meta.url), + "utf8", + ); + const { specs, packs, anchors } = extraction.counts; + const { nodes, edges } = extraction.graph; + const summary = `${String(specs)} specs · ${String(packs)} packs · ${String(anchors)} anchors → ${String(nodes.length)} nodes · ${String(edges.length)} edges (0 errors, 0 warnings)`; + expect(readme).toContain(`\`\`\`\n${summary}\n\`\`\``); + }); + it("extracts the example with zero findings", () => { expect(extraction.report.findings).toEqual([]); expect(extraction.counts).toEqual({ specs: 11, packs: 2, anchors: 5 }); diff --git a/test/recipes.test.ts b/test/recipes.test.ts index 6bf329b03..e58e14d48 100644 --- a/test/recipes.test.ts +++ b/test/recipes.test.ts @@ -506,6 +506,48 @@ describe("the agent-surface recipe corpus", () => { } }); + // A caller checks the shape the intro names; a recipe missing from its list reads an absent + // subject as an empty answer. + it("names in the intro every recipe that reports an absent subject, by its shape", async () => { + const intro = source.slice(0, source.indexOf("## 1.")).replace(/\s+/gu, " "); + const ordinals = (list: string | undefined) => + [...(list ?? "").matchAll(/\d+/gu)].map((match) => Number(match[0])); + const documented = { + foundFalse: ordinals( + /Recipes ([\d, and]+) answer an absent subject with `found: false`/u.exec(intro)?.[1], + ), + diagnosed: ordinals( + /\(recipes ([\d, and]+)\) report an absent Pack in `diagnostics`/u.exec(intro)?.[1], + ), + }; + // Inputs these recipes refuse before they look the Pack up. + const companions: Readonly>>> = { + 35: { base: derived.graph }, + 48: { commit: "0".repeat(40) }, + }; + const subjects = { pack: "pack:absent-v1", spec: "spec:absent", component: "component:absent" }; + const answered = { foundFalse: new Set(), diagnosed: new Set() }; + for (const recipe of recipes) { + for (const [key, id] of Object.entries(subjects)) { + const answer = await runRecipe(recipe, { ...companions[recipe.ordinal], [key]: id }); + if (typeof answer !== "object" || answer === null || Array.isArray(answer)) continue; + const record = answer as Record; + if (record.found === false) answered.foundFalse.add(recipe.ordinal); + const diagnostics = Array.isArray(record.diagnostics) ? record.diagnostics : []; + if ( + key === "pack" && + diagnostics.some((entry) => Object.values(asRecord(entry)).includes("pack")) + ) { + answered.diagnosed.add(recipe.ordinal); + } + } + } + expect(documented).toEqual({ + foundFalse: [...answered.foundFalse], + diagnosed: [...answered.diagnosed], + }); + }, 120_000); + it("keeps every body plain JavaScript with a return", () => { for (const recipe of recipes) { expect({ recipe: recipe.title, hasReturn: recipe.body.includes("return ") }).toEqual({ diff --git a/test/self-hosting-graph.test.ts b/test/self-hosting-graph.test.ts index df40b81ce..9a6c57d15 100644 --- a/test/self-hosting-graph.test.ts +++ b/test/self-hosting-graph.test.ts @@ -354,6 +354,42 @@ describe("the self-hosting corpus", () => { ).toEqual(expectedWarnings); }); + // The handbook tells a session which warnings to expect; naming one the oracle refuses would + // teach a regression as the baseline. + it("names in the handbook exactly the warning kinds the oracle retains", () => { + const handbook = readFileSync(join(repoRoot, "AGENTS.md"), "utf8").replace(/\s+/gu, " "); + const paragraph = handbook.split("The `check:self-hosting` step emits ")[1] ?? ""; + const firstSentence = paragraph.split(". ")[0] ?? ""; + expect([...firstSentence.matchAll(/`([a-z]+\/[a-z-]+)`/gu)].map((match) => match[1])).toEqual([ + ...new Set(expectedWarnings.map(({ validatorId }) => validatorId)), + ]); + }); + + // The registry's preamble: a decision that narrows another refines it, and the narrowed row + // names it, so a reader resolving the narrowed code by name never takes it as whole law. + it("names every narrowing decision on the narrowed decision's registry row", () => { + const rows = readFileSync(join(repoRoot, "docs/concept/DECISIONS.md"), "utf8") + .split("\n") + .filter((line) => /^\| MD-\d+ \|/u.test(line)); + const pointer = (row: string) => row.split(" | ").at(-1) ?? ""; + const unnamed = result.graph.edges + .filter( + (edge) => + edge.type === "refines" && + edge.from.startsWith("spec:decisions.") && + edge.to.startsWith("spec:decisions."), + ) + .flatMap((edge) => { + const row = rows.find((candidate) => + pointer(candidate).split("; narrowed by")[0]?.includes(`\`${edge.to}\``), + ); + return row === undefined || pointer(row).includes(`\`${edge.from}\``) + ? [] + : [`${edge.to} narrowed by ${edge.from}`]; + }); + expect(unnamed).toEqual([]); + }); + it("holds the frozen corpus totals", () => { // The literals are the corpus checkpoint. The authored arrays are measured against the same // literals rather than standing in for them, so a transcription slip in an oracle module diff --git a/test/skills.test.ts b/test/skills.test.ts index ebefa0174..a054c2b8f 100644 --- a/test/skills.test.ts +++ b/test/skills.test.ts @@ -1510,7 +1510,7 @@ describe("the reviewed skill teaching", () => { "## Bootstrap discipline", "## Answer a task with a recipe", "## The contract", - "## The entry adapters", + "## Entry adapters and reader joins", "## The shape of the graph", "## How delivery state derives", "## Empty answers that mislead", @@ -1530,6 +1530,25 @@ describe("the reviewed skill teaching", () => { } }); + // The demand map rules the adapters by what an agent holds; a by-id join taught as an adapter + // would send agents looking up ids they do not have. + it("names as entry adapters exactly the ones the demand map rules, apart from the reader joins", () => { + const reader = createReader(shippedCorpusGraph()); + const address = reader.resolveEntryAddress("spec:consumers.agent-surface#rule.entryAdapters"); + const index = Number(/^rule\[(\d+)\]$/u.exec(String(address.entry))?.[1]); + const rule = reader.specContext("spec:consumers.agent-surface")?.sections?.behavior?.rules?.[ + index + ]; + const ruled = [...String(rule).matchAll(/`(\w+)`/gu)].map((match) => match[1]); + expect(ruled).toHaveLength(3); + const section = flat(skillSection(surfacePath, "## Entry adapters and reader joins")); + const adapters = /the entry adapters are (.+?) \(/u.exec(section)?.[1] ?? ""; + expect([...adapters.matchAll(/`g\.(\w+)\(/gu)].map((match) => match[1])).toEqual(ruled); + expect(section).toContain( + "`g.specContext(id)`, `g.packContext(id)` and `g.resolveEntryAddress(address)` are joins frozen into the reader", + ); + }); + it("states the graph's flat topology and which node types carry lines, as the graph does", () => { const shape = flat(skillSection(surfacePath, "## The shape of the graph")); for (const clause of [ From d46f63409b918ecbdde9259987b0b0c4c896a4e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Tue, 3 Nov 2026 19:14:09 +0100 Subject: [PATCH 03/16] fix(validate): close honesty and carrier gaps from the whole-repository review L3-01: one delivery-fact spelling list, outside the public barrel, feeds the authoring-shape check, extraction's reserved keys and the Gherkin tag pattern, so the camelCase hasVerifier is refused everywhere. L1-04: Markdown list text refuses the block shapes the carrier rules list (nested bullets, ordered prefixes, pipes, block quotes, thematic breaks); code-span forms stay accepted. L1-05: readiness-floor evaluation and derived readiness skip a Spec with an unratified altitude instead of reporting floor failures for it. L1-03: not changed; reserving kind in Then slots needs an owner ruling. From the whole-repository review (PR #38). Each finding was verified by a second model family, and this lane's diff was reviewed by the other. Co-Authored-By: Claude Opus 5.5 --- src/extract/gherkin.ts | 16 ++++---- src/extract/markdown-body-content.ts | 8 ++++ src/extract/markdown-body-owner-support.ts | 5 +-- src/extract/markdown-yaml-policy.ts | 6 +-- src/extract/reify.ts | 6 +-- src/graph/delivery-fact-spellings.ts | 4 ++ src/validate/readiness-floor.ts | 18 +++++---- src/validate/validators.ts | 10 +++-- test/extract.test.ts | 32 ++++++++++++++++ test/markdown-reifier.test.ts | 37 +++++++++++++++++++ test/readiness.test.ts | 4 +- test/validators.test.ts | 43 +++++++++++++++++----- 12 files changed, 148 insertions(+), 41 deletions(-) create mode 100644 src/graph/delivery-fact-spellings.ts diff --git a/src/extract/gherkin.ts b/src/extract/gherkin.ts index 1a54474f7..451058716 100644 --- a/src/extract/gherkin.ts +++ b/src/extract/gherkin.ts @@ -1,3 +1,4 @@ +import { deliveryFactSpellings } from "../graph/delivery-fact-spellings.js"; import { normalizeAuthoring, entryKeyPattern } from "./authoring-normalization.js"; import { generateMessages } from "@cucumber/gherkin"; import { @@ -372,17 +373,14 @@ function tagHead(tagName: string): string { return dot === -1 ? body : body.slice(0, dot); } +const deliveryFactTagPattern = new RegExp(`^@(${deliveryFactSpellings.join("|")})(?=$|[.-])`, "iu"); + function authoredFactReason(name: string): string | undefined { - const head = - /^@(implemented|has-verifier|hasVerifier|observed|claim|status|lifecycle|workflow|architect)(?=$|[.-])/iu - .exec(name)?.[1] - ?.toLowerCase(); + if (deliveryFactTagPattern.test(name)) return "delivery-fact lookalike"; + const head = /^@(claim|status|lifecycle|workflow|architect)(?=$|[.-])/iu + .exec(name)?.[1] + ?.toLowerCase(); switch (head) { - case "implemented": - case "has-verifier": - case "hasverifier": - case "observed": - return "delivery-fact lookalike"; case "claim": return "authored claim lookalike"; case "status": diff --git a/src/extract/markdown-body-content.ts b/src/extract/markdown-body-content.ts index 3dd522fe7..7e0e579e3 100644 --- a/src/extract/markdown-body-content.ts +++ b/src/extract/markdown-body-content.ts @@ -258,6 +258,14 @@ export function parseSectionContent( const text = line.text.slice(2); if (text.trim().length === 0 || /^[\t ]/u.test(text)) addMarkdownFinding(findings, bodyFinding(file, line.line, "list text must not be empty")); + else if ( + isUnsupportedCommonMarkBlock(text) || + /^(?:(?:[-*+]|\d+[.)])(?:[\t ]|$)|[|>])/u.test(text) + ) + addMarkdownFinding( + findings, + bodyFinding(file, line.line, "nested or unsupported Markdown structure"), + ); else items.push({ text, line: line.line, afterH3: h3 !== undefined, fields: [] }); continue; } diff --git a/src/extract/markdown-body-owner-support.ts b/src/extract/markdown-body-owner-support.ts index b1173bdce..aad6516e6 100644 --- a/src/extract/markdown-body-owner-support.ts +++ b/src/extract/markdown-body-owner-support.ts @@ -1,11 +1,10 @@ +import { deliveryFactSpellings } from "../graph/delivery-fact-spellings.js"; import { extractFindingIds } from "./reify.js"; import type { Finding } from "../validate/contracts.js"; import { addMarkdownFinding, markdownFinding } from "./markdown-support.js"; export const reservedMarkdownProperties = new Set([ - "implemented", - "hasVerifier", - "observed", + ...deliveryFactSpellings, "claim", "deliveryFacts", "nodeType", diff --git a/src/extract/markdown-yaml-policy.ts b/src/extract/markdown-yaml-policy.ts index 3633f127e..8abcbf6ff 100644 --- a/src/extract/markdown-yaml-policy.ts +++ b/src/extract/markdown-yaml-policy.ts @@ -1,3 +1,4 @@ +import { deliveryFactSpellings } from "../graph/delivery-fact-spellings.js"; import { extractFindingIds } from "./reify.js"; import { setOwn } from "./set-own.js"; import { relationOptionsRefusal } from "./authoring-normalization.js"; @@ -16,10 +17,7 @@ const invalidIdFindingId = extractFindingIds.invalidId; 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", + ...deliveryFactSpellings, "claim", "deliveryFacts", "nodeType", diff --git a/src/extract/reify.ts b/src/extract/reify.ts index 2573d1437..2554cac4a 100644 --- a/src/extract/reify.ts +++ b/src/extract/reify.ts @@ -9,7 +9,7 @@ import type { SourceFile, } from "ts-morph"; -import { deliveryFactNames } from "../graph/schema.js"; +import { deliveryFactSpellings } from "../graph/delivery-fact-spellings.js"; import { CODE_ANCHOR_NAMESPACES, codeAnchorId, componentAnchorId, parseId, ref } from "../ids.js"; import type { IdParts } from "../ids.js"; import { codeAnchor } from "../model/code-anchor.js"; @@ -107,9 +107,7 @@ const SPEC_SECTION_NAME_SET = new Set(SPEC_SECTION_NAMES); * reifies through and the `honesty/authoring-shape` validator sees it in the model. */ 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", + ...deliveryFactSpellings, "deliveryFacts", "claim", "nodeType", diff --git a/src/graph/delivery-fact-spellings.ts b/src/graph/delivery-fact-spellings.ts new file mode 100644 index 000000000..5eb9ce9e9 --- /dev/null +++ b/src/graph/delivery-fact-spellings.ts @@ -0,0 +1,4 @@ +import { deliveryFactNames } from "./schema.js"; + +/** Authored carriers reserve both the graph names and the camelCase spelling. */ +export const deliveryFactSpellings = [...deliveryFactNames, "hasVerifier"] as const; diff --git a/src/validate/readiness-floor.ts b/src/validate/readiness-floor.ts index f00dfb2e3..9dcbb75d8 100644 --- a/src/validate/readiness-floor.ts +++ b/src/validate/readiness-floor.ts @@ -1,6 +1,6 @@ import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; import { codeAnchor } from "../model/code-anchor.js"; -import { SPEC_KINDS, SPEC_READINESS } from "../model/descriptors.js"; +import { SPEC_ALTITUDES, SPEC_KINDS, SPEC_READINESS } from "../model/descriptors.js"; import type { SpecKind, SpecReadiness } from "../model/descriptors.js"; import type { SpecSectionName } from "../model/sections.js"; import { exampleMatchesParentVocabulary } from "../graph/example-space.js"; @@ -577,6 +577,7 @@ export interface ReadinessFloorFailure { /** A clause row of the table, read with its literal id. */ type ReadinessTableClause = ReadinessClause & { readonly id: ReadinessClauseId }; +const ratifiedAltitudes: ReadonlySet = new Set(SPEC_ALTITUDES); const ratifiedKinds: ReadonlySet = new Set(SPEC_KINDS); const ratifiedReadiness: ReadonlySet = new Set(SPEC_READINESS); @@ -626,9 +627,12 @@ export function evaluateReadinessFloor( // Foreign graph data can carry unratified strings in the typed descriptor slots; those are the // descriptor conformance errors (`spec:validation.claim-separation` — validateGraph fails closed), and the - // evaluator stays total: over an unratified kind or rung it evaluates no clauses rather - // than dereferencing the evidence table into a throw or guessing a rung. - if (!ratifiedKinds.has(node.specKind) || !ratifiedReadiness.has(target)) { + // evaluator stays total: over an unratified kind, altitude or target rung it evaluates no clauses. + if ( + !ratifiedKinds.has(node.specKind) || + !ratifiedAltitudes.has(node.altitude) || + !ratifiedReadiness.has(target) + ) { return []; } @@ -662,14 +666,14 @@ export function evaluateReadinessFloor( * floor (MD-13). The subject's stated rung does not limit which floors are evaluated. The target * clause reads each target's stated readiness, including when the subject is its own target. * Returns `undefined` when even the `idea` - * clauses fail. Total over foreign data: an unratified `specKind` cannot dereference the evidence - * table, so no rung derives — the descriptor conformance error owns that finding, exactly as in + * clauses fail. Total over foreign data: no rung derives over an unratified kind or altitude — + * the descriptor conformance error owns that finding, exactly as in * the evaluator. The divergence reading: derived *below* stated is the honesty signal the floor * check fails on; derived at-or-above stated is ordinary information — the floor is never a * quota, and a spec may honestly state less than it clears. */ export function deriveReadiness(node: PrimitiveNode, index: GraphIndex): SpecReadiness | undefined { - if (!ratifiedKinds.has(node.specKind)) { + if (!ratifiedKinds.has(node.specKind) || !ratifiedAltitudes.has(node.altitude)) { return undefined; } diff --git a/src/validate/validators.ts b/src/validate/validators.ts index 4482d4c31..57378b91b 100644 --- a/src/validate/validators.ts +++ b/src/validate/validators.ts @@ -1,6 +1,7 @@ import { resolveEntryAddress } from "../graph/entry-address.js"; import { computeDeliveryFacts, isResolvingTestAnchorVerify } from "../graph/delivery-facts.js"; import { isResolvingOracleModel, ownsExampleSpace } from "../graph/oracle-bindings.js"; +import { deliveryFactSpellings } from "../graph/delivery-fact-spellings.js"; import { deliveryFactNames, graphClaims, graphEdgeTypes, graphNodeTypes } from "../graph/schema.js"; import { CODE_ANCHOR_NAMESPACES, @@ -1583,9 +1584,12 @@ function checkAuthoringShape(node: PrimitiveNode, findings: Finding[]): void { return; } - for (const factName of deliveryFactNames) { - if (factName in carrier) { - appendSmuggledFact(factName, `${basePath}.${factName}`); + for (const spelling of deliveryFactSpellings) { + if (spelling in carrier) { + appendSmuggledFact( + spelling === "hasVerifier" ? "has-verifier" : spelling, + `${basePath}.${spelling}`, + ); } } }; diff --git a/test/extract.test.ts b/test/extract.test.ts index 737ca9b0d..d58222393 100644 --- a/test/extract.test.ts +++ b/test/extract.test.ts @@ -295,6 +295,38 @@ describe("extraction corpora", () => { expect(JSON.stringify(node)).not.toContain("behaviour"); }); + it.each(["design", "behavior"])( + "reports every reserved delivery-fact spelling reified through %s", + (section) => { + for (const spelling of ["implemented", "has-verifier", "hasVerifier", "observed"]) { + const reified = reifyTypeScriptCarrier( + `import { spec, specId } from "@libar-dev/software-delivery-protocol"; +export const carrier = spec({ + id: specId("spec:probe.authored-fact"), + title: "Keep facts derived", + kind: "behavior", altitude: "story", readiness: "idea", + intent: { outcome: "Keep facts derived." }, + ${section}: { "${spelling}": "yes" }, +});`, + "authored-fact.sdp.ts", + ); + expect(reified.findings).toEqual([]); + const graph = deriveGraph(reified.specs, reified.packs, []); + expect( + validateGraph(graph).findings.filter( + (finding) => finding.validatorId === graphValidatorIds.authoringShape, + ), + ).toMatchObject([ + { + severity: "error", + relatedId: spelling === "hasVerifier" ? "has-verifier" : spelling, + path: `${section}.${spelling}`, + }, + ]); + } + }, + ); + it("unrecognized in-section property: warns before canonical serialization drops it", () => { const reified = reifyTypeScriptCarrier( `import { spec, specId } from "@libar-dev/software-delivery-protocol"; diff --git a/test/markdown-reifier.test.ts b/test/markdown-reifier.test.ts index 589bfe59e..8788c1a8a 100644 --- a/test/markdown-reifier.test.ts +++ b/test/markdown-reifier.test.ts @@ -1097,6 +1097,43 @@ export const invalid = spec({ id: specId("spec:carrier.invalid-prose"), title: " expect(result.findings[0]?.validatorId).toBe("extract/invalid-markdown-structure"); }); + it.each([ + "> quote", + "| table", + "* nested", + "+ nested", + "- nested", + "- - x", + "-", + "*", + "+", + "1.", + "1)", + "1. numbered", + "1) numbered", + "---", + "***", + "___", + "===", + " { + const result = reify(carrierBody(`# Title\n## Rule\n- ${text}`, "rule")); + + expect(result.specs).toEqual([]); + expect(result.findings).toMatchObject([ + { + validatorId: "extract/invalid-markdown-structure", + severity: "error", + line: 10, + message: "nested or unsupported Markdown structure", + }, + ]); + + const quoted = reify(carrierBody(`# Title\n## Rule\n- \`${text}\``, "rule")); + expect(quoted.findings).toEqual([]); + expect(quoted.specs).toHaveLength(1); + }); + it.each([ [ "frontmatter title", diff --git a/test/readiness.test.ts b/test/readiness.test.ts index 09c55369e..9defd48a7 100644 --- a/test/readiness.test.ts +++ b/test/readiness.test.ts @@ -690,15 +690,17 @@ describe("the floor for a target rung and the targets a dependency failure names ).toEqual(["at-least-one-relation", "kind-evidence-present"]); }); - it("yields no failure for an unratified kind or an unratified target rung", () => { + it("yields no failure for an unratified kind, altitude or target rung", () => { const { node, index } = indexedSubject(subjectId, [ subjectStating("ready"), basisStating("scoped"), ]); const foreignKind = { ...node, specKind: "epic" } as unknown as PrimitiveNode; const foreignStated = { ...node, readiness: "shipped" } as unknown as PrimitiveNode; + const foreignAltitude = { ...node, altitude: "planet" } as unknown as PrimitiveNode; expect(evaluateReadinessFloor(foreignKind, index, "ready")).toEqual([]); + expect(evaluateReadinessFloor(foreignAltitude, index, "ready")).toEqual([]); expect(evaluateReadinessFloor(node, index, "shipped" as SpecReadiness)).toEqual([]); // An unratified stated rung yields nothing on its own, but a named target rung is evaluated. expect(evaluateReadinessFloor(foreignStated, index)).toEqual([]); diff --git a/test/validators.test.ts b/test/validators.test.ts index b017e387a..024b2ad30 100644 --- a/test/validators.test.ts +++ b/test/validators.test.ts @@ -802,6 +802,29 @@ describe("graph validators", () => { expect(findings[0]?.message).toContain("omits the delivery fact"); }); + it.each(["design", "behavior", "intent", "constraints"])( + "refuses a camelCase delivery fact on the %s carrier", + (section) => { + const carrier = { hasVerifier: "yes" }; + const node = { + ...ideaPrimitive("spec:probe.authored-fact", "Keep facts derived."), + sections: { [section]: section === "constraints" ? [carrier] : carrier }, + } as unknown as PrimitiveNode; + const findings = validateGraph(syntheticGraph([node], [])).findings.filter( + (finding) => finding.validatorId === graphValidatorIds.authoringShape, + ); + + expect(findings).toMatchObject([ + { + severity: "error", + subjectId: node.id, + relatedId: "has-verifier", + path: `${section}${section === "constraints" ? "[0]" : ""}.hasVerifier`, + }, + ]); + }, + ); + it("fails closed on unratified descriptor values — a conformance error, never a crash or a silent floor skip", () => { const bogusKind = { ...ideaPrimitive("spec:orders.create-order", "Turn a valid cart into an order."), @@ -815,13 +838,13 @@ describe("graph validators", () => { const bogusAltitude = { ...ideaPrimitive("spec:orders.order-management", "Define order management."), altitude: "initiative", + readiness: "ready", } as unknown as PrimitiveNode; // Evaluating the scoped floor over specKind "saga" used to dereference the evidence table; // the seam must report, never throw. - const findings = validateGraph( - syntheticGraph([bogusKind, bogusReadiness, bogusAltitude], []), - ).findings; + const graph = syntheticGraph([bogusKind, bogusReadiness, bogusAltitude], []); + const findings = validateGraph(graph).findings; const descriptorErrors = findings.filter( (finding) => @@ -835,15 +858,15 @@ describe("graph validators", () => { ]); expect(descriptorErrors.every((finding) => finding.severity === "error")).toBe(true); - // Fail closed: no floor evaluation over an unratified kind or readiness — the conformance - // error owns the finding, instead of a crash (bogus kind) or a silent skip (bogus readiness). + // Descriptor conformance owns the finding; no floor or derived rung is meaningful. expect( - findings.filter( - (finding) => - finding.validatorId === graphValidatorIds.readinessFloor && - (finding.subjectId === bogusKind.id || finding.subjectId === bogusReadiness.id), - ), + findings.filter((finding) => finding.validatorId === graphValidatorIds.readinessFloor), ).toEqual([]); + const reader = createReader(graph); + for (const node of [bogusKind, bogusAltitude]) { + expect(reader.specContext(node.id)?.derivedReadiness).toBeUndefined(); + expect(reader.specContext(node.id)?.floorFailures).toEqual([]); + } }); it("returns a valid empty aggregate report without throwing", () => { From acc0bd569501f96e04e4ecf099f3eb4ea5f1ff7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Tue, 3 Nov 2026 19:19:00 +0100 Subject: [PATCH 04/16] fix(runner): dispatch shared contract keys by phase and refuse an unspecified oracle L1-01: a key shared across phases gets one handler that branches on the occurrence's phase, so the first When invokes the product and a Then compares; StepFn gains an optional third step parameter. L1-02: an unspecified oracle never matches a Then, so completion raises the existing noMatchingThen diagnostic and no observation or assertion runs. From the whole-repository review (PR #38). Each finding was verified by a second model family, and this lane's diff was reviewed by the other. Co-Authored-By: Claude Opus 5.5 --- src/codegen/contracts.ts | 35 ++++-- src/runner/index.ts | 8 +- src/testing/index.ts | 2 +- test/codegen.test.ts | 245 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 278 insertions(+), 12 deletions(-) diff --git a/src/codegen/contracts.ts b/src/codegen/contracts.ts index 15af004dd..0c1e0bc29 100644 --- a/src/codegen/contracts.ts +++ b/src/codegen/contracts.ts @@ -544,17 +544,26 @@ function renderRunnableRegistrar( } renderedBindings.add(step.key); const bindingKey = ` ${JSON.stringify(step.key)}:`; - const opening = - step.kind === "given" + // One key may occur in several phases; its handler must read the current occurrence. + const phases = new Set( + steps.filter((entry) => entry.key === step.key).map((entry) => entry.kind), + ); + const shared = phases.size > 1; + const opening = shared + ? `async (execution, params, step) => {` + : step.kind === "given" ? `() => {` : step.kind === "when" ? `async (execution) => {` : `(execution, params) => {`; const exceedsLine = bindingKey.length + 1 + opening.length > 100; - const wrappedThenParams = step.kind === "then" && exceedsLine && bindingKey.length + 2 <= 100; - const wrappedWhenParam = - step.kind === "when" && exceedsLine && bindingKey.length + " async (".length <= 100; - const hanging = exceedsLine && !wrappedThenParams && !wrappedWhenParam; + const wrappedThenParams = + !shared && step.kind === "then" && exceedsLine && bindingKey.length + 2 <= 100; + const wrappedAsyncParams = + (shared || step.kind === "when") && + exceedsLine && + bindingKey.length + " async (".length <= 100; + const hanging = exceedsLine && !wrappedThenParams && !wrappedAsyncParams; const bodyIndent = hanging ? " " : " "; const closeIndent = hanging ? " " : " "; @@ -563,9 +572,13 @@ function renderRunnableRegistrar( lines.push(` execution,`); lines.push(` params,`); lines.push(` ) => {`); - } else if (wrappedWhenParam) { + } else if (wrappedAsyncParams) { lines.push(`${bindingKey} async (`); lines.push(` execution,`); + if (shared) { + lines.push(` params,`); + lines.push(` step,`); + } lines.push(` ) => {`); } else { lines.push(hanging ? bindingKey : `${bindingKey} ${opening}`); @@ -574,7 +587,13 @@ function renderRunnableRegistrar( } } - if (step.kind === "given") { + if (shared) { + lines.push(`${bodyIndent}if (step.kind === "when") {`); + lines.push(`${bodyIndent} await invokeRunnableExample(execution);`); + lines.push(`${bodyIndent}} else if (step.kind === "then") {`); + lines.push(`${bodyIndent} compareContractOutcome(execution, { ...step, params });`); + lines.push(`${bodyIndent}}`); + } else if (step.kind === "given") { lines.push( `${bodyIndent}// Given state is materialized by createWorld from the generated point.`, ); diff --git a/src/runner/index.ts b/src/runner/index.ts index 36288b3ac..dca49fc13 100644 --- a/src/runner/index.ts +++ b/src/runner/index.ts @@ -43,7 +43,11 @@ export interface ExampleContract }[S][]; } -export type StepFn = (world: W, params: P) => void | Promise; +export type StepFn = ( + world: W, + params: P, + step: ContractStep, +) => void | Promise; /** Every step, only the steps — and each handler's params typed per step. */ export type StepBindings> = { @@ -138,7 +142,7 @@ export function planExample bindings[step.key](world, step.params), + run: (world: W) => bindings[step.key](world, step.params, step), })), }; } diff --git a/src/testing/index.ts b/src/testing/index.ts index e457e5e0e..4fa015986 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -109,7 +109,7 @@ export function compareContractOutcome( ): void { const oracle = oracleForComparison(execution); - if (step.key !== oracle.kind) { + if (oracle.kind === "unspecified" || step.key !== oracle.kind) { return; } diff --git a/test/codegen.test.ts b/test/codegen.test.ts index 7d4db593c..9c04e2c62 100644 --- a/test/codegen.test.ts +++ b/test/codegen.test.ts @@ -1,7 +1,21 @@ -import { describe, expect, it } from "vitest"; +import { Script } from "node:vm"; +import ts from "typescript"; +import { format } from "prettier"; +import { describe, expect, it, vi } from "vitest"; +import { planExample, runExamplePlan } from "../src/runner/index.js"; +import type { ExampleContract, ParamShape, StepBindings } from "../src/runner/index.js"; +import { + compareContractOutcome, + completeRunnableExample, + createRunnableExample, + invokeRunnableExample, +} from "../src/testing/index.js"; +import type { RunnableExampleAdapters, RunnableExampleWorld } from "../src/testing/index.js"; import { createReader, + deriveGraph, + reifyMarkdownCarrier, evaluateBody, contractsFindingIds, generateContracts, @@ -74,7 +88,236 @@ const BOUND_GWT = { then: ["an order is created with total {total: 100}"], }; +type ProbeAdapters = RunnableExampleAdapters, { kind: string }>; +type ProbeExecution = RunnableExampleWorld, { kind: string }>; + +// Evaluate only this test's generated modules, in the same realm as the runtime comparator. +function loadGeneratedModule( + source: string, + require: (name: string) => unknown = () => { + throw new Error("unexpected generated import"); + }, +): Record { + const js = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const exports: Record = {}; + const evaluate = new Script(`(function(exports, require) { ${js}\n })`).runInThisContext() as ( + exports: Record, + require: (name: string) => unknown, + ) => void; + evaluate(exports, require); + return exports; +} + +function generateProbe(steps: string) { + const parent = reifyMarkdownCarrier( + `--- +id: spec:probe.runtime +kind: behavior +altitude: story +readiness: scoped +relations: {} +--- +# Runtime probe +## Intent +- outcome: Exercise generated step dispatch. +## Example space +\`\`\`gwt-vocabulary +${steps} +\`\`\` +`, + "parent.sdp.md", + ); + const child = reifyMarkdownCarrier( + `--- +id: spec:probe.runtime.shared +kind: example +altitude: story +readiness: scoped +relations: + refines: spec:probe.runtime + verifies: spec:probe.runtime +--- +# Shared step +## Intent +- outcome: Exercise generated step dispatch. +\`\`\`gwt +${steps} +\`\`\` +`, + "child.sdp.md", + ); + expect([...parent.findings, ...child.findings]).toEqual([]); + const base = deriveGraph([...parent.specs, ...child.specs], [], []); + const generated = generateContracts({ + ...base, + nodes: [ + ...base.nodes, + { + id: "test:probe.runtime", + nodeType: "Anchor", + claim: "anchored", + file: "test/probe.test.ts", + line: 1, + }, + ], + edges: [ + ...base.edges, + { + from: "test:probe.runtime", + type: "verifies", + to: "spec:probe.runtime.shared", + claim: "anchored", + }, + ], + }); + expect(generated.findings).toEqual([]); + return generated; +} + +function generatedProbe(steps: string, adapters: ProbeAdapters): () => Promise { + const generated = generateProbe(steps); + const contractModule = loadGeneratedModule( + generated.files.get("probe.runtime.shared.contract.ts") ?? "", + ); + let run: (() => Promise) | undefined; + const registrar = loadGeneratedModule( + generated.registrars.get("test/probe.runtime.shared.test.generated.ts") ?? "", + (name) => { + if (name.endsWith(".contract.js")) return contractModule; + if (name !== "@libar-dev/software-delivery-protocol/testing") throw new Error(name); + return { + compareContractOutcome, + invokeRunnableExample, + registerRunnableExample( + contract: ExampleContract>, + point: Record, + required: readonly string[], + supplied: ProbeAdapters, + bindings: StepBindings>, + ) { + run = async () => { + const execution = createRunnableExample( + contract.spec, + point, + required, + supplied, + contract.steps.filter((step) => step.kind === "then"), + ); + await runExamplePlan(planExample(contract, bindings), execution); + await completeRunnableExample(execution); + }; + }, + }; + }, + ); + const register = registrar.registerShared as (adapters: ProbeAdapters) => void; + register(adapters); + if (!run) throw new Error("the generated registrar did not register its example"); + return run; +} + describe("the contracts codegen stage", () => { + it.each(["ready", "[#readyKey] ready"])( + "invokes a throwing product when Given and When share %s", + async (shared) => { + const invoke = vi.fn(() => { + throw new Error("product invoked"); + }); + const observe = vi.fn(() => ({ kind: "done" })); + const run = generatedProbe(`Given ${shared}\nWhen ${shared}\nThen done`, { + createWorld: () => ({}), + invoke, + observe, + expected: () => ({ kind: "done" }), + }); + await expect(run()).rejects.toThrow("at step: When ready\nproduct invoked"); + expect(invoke).toHaveBeenCalledTimes(1); + expect(observe).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["Given ready\nWhen ready\nThen ready", "ready"], + ["Given ready\nWhen run\nThen ready", "ready"], + ["Given start\nWhen ready\nThen ready", "ready"], + ])("dispatches every phase of a shared key: %s", async (steps, outcome) => { + const trace: string[] = []; + const run = generatedProbe(steps, { + createWorld: () => { + trace.push("given"); + return {}; + }, + invoke: () => { + trace.push("when"); + }, + observe: () => { + trace.push("then"); + return { kind: outcome }; + }, + expected: () => ({ kind: outcome }), + assertions: () => { + trace.push("assertions"); + }, + }); + await run(); + expect(trace).toEqual(["given", "when", "then", "assertions"]); + }); + + it.each([40, 60, 85, 110])( + "formats shared-key handlers with %i-character keys for adoption", + async (length) => { + const shared = "a shared step ".padEnd(length, "x"); + const generated = generateProbe(`Given ${shared}\nWhen ${shared}\nThen all done`); + const registrar = generated.registrars.get("test/probe.runtime.shared.test.generated.ts"); + expect(registrar).toBeDefined(); + const bindings = (source: string) => + source.split(" const bindings = {")[1]?.split(" } satisfies StepBindings")[0]; + expect(bindings(registrar ?? "")).toBe( + bindings(await format(registrar ?? "", { parser: "typescript", printWidth: 100 })), + ); + }, + ); + + it("preserves the noMatchingThen diagnostic and cause for an unspecified oracle", async () => { + const observe = vi.fn(() => ({ kind: "unspecified" })); + const assertions = vi.fn(); + const run = generatedProbe("Given ready\nWhen run\nThen done", { + createWorld: () => ({}), + invoke: () => undefined, + observe, + expected: () => ({ kind: "unspecified" }), + assertions, + }); + + await expect(run()).rejects.toMatchObject({ + message: + 'scenario spec:probe.runtime.shared: oracle selected "unspecified"; available Then steps: "done"', + cause: new Error('oracle kind "unspecified" has no matching Then'), + }); + expect(observe).not.toHaveBeenCalled(); + expect(assertions).not.toHaveBeenCalled(); + }); + + it.each(["unspecified", "[#unspecified] a coverage gap"])( + "refuses an oracle coverage gap even when Then is %s", + async (then) => { + const observe = vi.fn(() => ({ kind: "unspecified" })); + const assertions = vi.fn(); + const run = generatedProbe(`Given ready\nWhen run\nThen ${then}`, { + createWorld: () => ({}), + invoke: () => undefined, + observe, + expected: () => ({ kind: "unspecified" }), + assertions, + }); + await expect(run()).rejects.toThrow('oracle selected "unspecified"'); + expect(observe).not.toHaveBeenCalled(); + expect(assertions).not.toHaveBeenCalled(); + }, + ); + it("emits a space contract per example-space parent and a step contract per bindable example", () => { const graph = deriveFixtureGraph({ specs: [parent(VOCABULARY), child(BOUND_GWT)] }); const generated = generateContracts(graph); From 61e8776c3730388c8572057ed4717c566ef0be86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Tue, 3 Nov 2026 19:19:00 +0100 Subject: [PATCH 05/16] test: bind the body-grammar and structural-anchor regression suites as verifiers L5-01: test/markdown-reifier.test.ts declares test:protocol.markdown-body-grammar-regressions, verifying spec:carrier.markdown-body-grammar. L5-02: test/structural-anchors.test.ts declares test:protocol.structural-anchor-regressions, verifying spec:model.anchors. The self-hosting oracle pins move by the two new anchors, nodes and binding edges. From the whole-repository review (PR #38). Each finding was verified by a second model family, and this lane's diff was reviewed by the other. Co-Authored-By: Claude Opus 5.5 --- test/markdown-reifier.test.ts | 12 +++++++++++- test/self-hosting-graph.test.ts | 10 +++++----- test/self-hosting-oracle/anchors.ts | 22 ++++++++++++++++++++++ test/structural-anchors.test.ts | 8 ++++++++ 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/test/markdown-reifier.test.ts b/test/markdown-reifier.test.ts index 589bfe59e..1728be5b3 100644 --- a/test/markdown-reifier.test.ts +++ b/test/markdown-reifier.test.ts @@ -99,7 +99,17 @@ const envelopeContractTestAnchor = specTest({ label: "frontmatter contract tests verify the Markdown envelope", verifies: ref("spec:carrier.envelope-contract"), }); -void [proseOwnershipTestAnchor, markdownParserTestAnchor, envelopeContractTestAnchor]; +const markdownBodyGrammarTestAnchor = specTest({ + id: testAnchorId("test:protocol.markdown-body-grammar-regressions"), + label: "checks Markdown body grammar refusals", + verifies: ref("spec:carrier.markdown-body-grammar"), +}); +void [ + proseOwnershipTestAnchor, + markdownParserTestAnchor, + envelopeContractTestAnchor, + markdownBodyGrammarTestAnchor, +]; describe("Markdown frontmatter reifier", () => { const lawfulPack = `--- diff --git a/test/self-hosting-graph.test.ts b/test/self-hosting-graph.test.ts index df40b81ce..08e8ce7e8 100644 --- a/test/self-hosting-graph.test.ts +++ b/test/self-hosting-graph.test.ts @@ -358,13 +358,13 @@ describe("the self-hosting corpus", () => { // The literals are the corpus checkpoint. The authored arrays are measured against the same // literals rather than standing in for them, so a transcription slip in an oracle module // cannot certify itself by moving both sides of a comparison at once. - expect(result.counts).toEqual({ specs: 456, packs: 9, anchors: 607 }); + expect(result.counts).toEqual({ specs: 456, packs: 9, anchors: 609 }); expect(expectedSpecs).toHaveLength(456); expect(expectedPackMembers).toHaveLength(456); - expect(expectedAnchors).toHaveLength(607); - expect(expectedBindingEdges).toHaveLength(810); - expect(result.graph.nodes).toHaveLength(1072); - expect(result.graph.edges).toHaveLength(2978); + expect(expectedAnchors).toHaveLength(609); + expect(expectedBindingEdges).toHaveLength(812); + expect(result.graph.nodes).toHaveLength(1074); + expect(result.graph.edges).toHaveLength(2980); }); it("permits a named second diagram view in the authored element-id contract", () => { diff --git a/test/self-hosting-oracle/anchors.ts b/test/self-hosting-oracle/anchors.ts index 9d3446b75..1156f32ed 100644 --- a/test/self-hosting-oracle/anchors.ts +++ b/test/self-hosting-oracle/anchors.ts @@ -20,6 +20,22 @@ export interface ExpectedAnchor { // re-measured under plan 41 export const expectedAnchors: readonly ExpectedAnchor[] = [ + { + id: "test:protocol.markdown-body-grammar-regressions", + nodeType: "Anchor", + label: "checks Markdown body grammar refusals", + file: "test/markdown-reifier.test.ts", + constant: "markdownBodyGrammarTestAnchor", + site: 'describe("Markdown frontmatter reifier",', + }, + { + id: "test:protocol.structural-anchor-regressions", + nodeType: "Anchor", + label: "checks structural anchor refusals and binding contradictions", + file: "test/structural-anchors.test.ts", + constant: "structuralAnchorsTestAnchor", + site: 'describe("structural code anchors",', + }, { id: "test:protocol.vault-priority", nodeType: "Anchor", @@ -6015,6 +6031,12 @@ export const expectedBindingEdges = [ ["impl:protocol.gherkin-authoring", "satisfies", "spec:carrier.gherkin-authoring"], ["impl:protocol.markdown-parser", "satisfies", "spec:carrier.markdown-parser"], ["test:protocol.markdown-parser", "verifies", "spec:carrier.markdown-parser"], + [ + "test:protocol.markdown-body-grammar-regressions", + "verifies", + "spec:carrier.markdown-body-grammar", + ], + ["test:protocol.structural-anchor-regressions", "verifies", "spec:model.anchors"], ["impl:protocol.envelope-contract", "satisfies", "spec:carrier.envelope-contract"], ["test:protocol.envelope-contract", "verifies", "spec:carrier.envelope-contract"], ["impl:protocol.markdown-body-grammar", "satisfies", "spec:carrier.prose-ownership-rule"], diff --git a/test/structural-anchors.test.ts b/test/structural-anchors.test.ts index 342a3ff23..f0cc0c52e 100644 --- a/test/structural-anchors.test.ts +++ b/test/structural-anchors.test.ts @@ -11,6 +11,7 @@ import { schemaVersion, validateGraph, } from "../src/index.js"; +import { ref, specTest, testAnchorId } from "@libar-dev/software-delivery-protocol"; import type { GraphEdge, GraphNode, GraphSchema } from "../src/index.js"; const temporaryRoots: string[] = []; @@ -70,6 +71,13 @@ afterAll(() => { } }); +const structuralAnchorsTestAnchor = specTest({ + id: testAnchorId("test:protocol.structural-anchor-regressions"), + label: "checks structural anchor refusals and binding contradictions", + verifies: ref("spec:model.anchors"), +}); +void structuralAnchorsTestAnchor; + describe("structural code anchors", () => { it("derives anchored memberOf and uses edges while accepting a multi-node uses cycle", () => { const result = extract({ From 2094790bc05dfb7db05979f071df8541dc2db39f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Tue, 3 Nov 2026 19:19:16 +0100 Subject: [PATCH 06/16] specs: apply the review's proposal on the agent surface and accepted answers From the whole-repository review: two ready consumer Specs have drifted behind the realized agent surface, and three provisional answers the owner accepted in PR #36 are recorded only in its description. Proposal base: b5e0a8be. P1: wrapIdiom calls the recipe through the `recipes` binding P2: the reader's method inventory names resolveEntryAddress P3: the reader gains the #entryAddressResolution rule; its rule keys shift P4: delivery-record-cleanup gains the #inventoryFirst rule P5: #durableContent keeps blocking with the accepted provisional P6: cited-line-text records #lineText's answer as the #lineRecorded rule P7: cited-line-text records #lineBound's answer as a design entry; readiness stays scoped From the whole-repository review (PR #38). Each finding was verified by a second model family, and this lane's diff was reviewed by the other. Co-Authored-By: Claude Opus 5.5 --- .../agent-surface.answer-shapes.sdp.md | 2 +- specs/consumers/reader.sdp.gherkin | 3 +- .../spec-studio.cited-line-text.sdp.md | 16 +++---- specs/protocol/delivery-record-cleanup.sdp.md | 6 ++- test/self-hosting-oracle/consumers.ts | 42 +++++++------------ test/self-hosting-oracle/protocol.ts | 10 ++++- 6 files changed, 38 insertions(+), 41 deletions(-) diff --git a/specs/consumers/agent-surface.answer-shapes.sdp.md b/specs/consumers/agent-surface.answer-shapes.sdp.md index 9ab1da372..baea6c7e5 100644 --- a/specs/consumers/agent-surface.answer-shapes.sdp.md +++ b/specs/consumers/agent-surface.answer-shapes.sdp.md @@ -39,4 +39,4 @@ A usability trial gave agents only the skills, the recipe catalog and `sdp q` an - preflightRungs: The Spec context carries `statedNextRung` when one exists and `statedNextRungFailures`; promotion preflight reads those fields and the floor's next rung without computing a floor, and renders a missing next rung as null. - designBacklog: recipe 33, the design backlog, takes no parameter and returns `{ totals: { specs, byFamily, excludedExamples, excludedDecisions, withReferences }, rows }`, each row `{ id, specKind, altitude, floorReached, statedNextRungFailures: [clauseId], design: { entries, declarations, openQuestions, blockingQuestions }, referencedBy, packs }`, sorted by id. - catalogIndex: the catalog opens with a table of every recipe, its number, name, parameters and a one-line use, before the contract; the build writes one file per body and none for the index. -- wrapIdiom: to filter a shipped answer, the caller wraps the shipped body in an async arrow function that takes `params`, inside its own body, `const answer = await (async (params) => { })({ });`, and returns a filtered value, so the caller's own parameters never reach the recipe as names it does not take; the catalog shows the shell form and states that the caller's part must hold no shell expansion. +- wrapIdiom: to filter a shipped answer, the caller calls the recipe through the `recipes` binding with only the recipe's own parameters, `const answer = await recipes(, { });`, inside its own body, and returns a filtered value, so the caller's own parameters never reach the recipe as names it does not take and the shipped body is never inlined into the caller's; the catalog shows the shell form and states that the caller's part must hold no shell expansion. diff --git a/specs/consumers/reader.sdp.gherkin b/specs/consumers/reader.sdp.gherkin index fe74dfca8..1c03be16f 100644 --- a/specs/consumers/reader.sdp.gherkin +++ b/specs/consumers/reader.sdp.gherkin @@ -71,7 +71,8 @@ Feature: The reader bridges agent entry points to composable graph context Then the coverage-unknown files number {coverageUnknownCount: 1} Rule: `createReader` constructs a fresh thin typed loader that indexes graph joins and claims and computes delivery facts and validation findings once, derives readiness when a caller asks, and returns plain composable data without persisting state. - Rule: The reader exposes `graph`, the raw graph it reads, and the methods `specs`, `packs`, `findings`, `findByConcept`, `byFile`, `blastRadius`, `specContext` and `packContext`; a join is added only when a second machine consumer needs it and hand-rolled attempts get it wrong. + Rule: The reader exposes `graph`, the raw graph it reads, and the methods `specs`, `packs`, `findings`, `findByConcept`, `byFile`, `blastRadius`, `resolveEntryAddress`, `specContext` and `packContext`; a join is added only when a second machine consumer needs it and hand-rolled attempts get it wrong. + Rule: [#entryAddressResolution] `resolveEntryAddress` resolves one entry address with the grammar and entry rules that prose validation and entry citations use, and returns `{ entry, resolves, reason }`: `entry` names the resolved entry, such as `rule[2]`, `question[0]` or `design.wrapIdiom`, or is null; `reason` is null when the address resolves and otherwise `malformed`, `spec`, `section` or `entry`; recipe 51 and the vault projection are its machine consumers. Rule: [#conceptEntry] `findByConcept` matches ids, titles, anchor labels, Pack framing, Spec narrative, and reified section content, including model term keys, and names the fields a node matched on rather than returning a bare hit. Rule: [#fileEntry] `byFile` answers with the nodes the graph records at the path and with the Specs those nodes reach, so a source file carrying a binding names the Spec it binds and a carrier file names the Spec authored in it. Rule: [#blastRadius] `blastRadius` maps changed files to Specs and Packs authored at them and Specs reached through `satisfies`, `references`, `verifies` or a usable oracle `models` binding, plus their explicit one-hop at-risk neighbors; it reports every file the graph records nothing at as `coverageUnknown` and every changed code unit with no Spec linkage as `unlinked`, and neither list claims exhaustive coverage. diff --git a/specs/consumers/spec-studio.cited-line-text.sdp.md b/specs/consumers/spec-studio.cited-line-text.sdp.md index 008796964..5a7c65600 100644 --- a/specs/consumers/spec-studio.cited-line-text.sdp.md +++ b/specs/consumers/spec-studio.cited-line-text.sdp.md @@ -13,7 +13,7 @@ relations: --- # A pinned declaration shows the words of each test line that cites it -A reader checking a pinned declaration reaches the test line that cites it and then follows a source link to read the assertion. The words of that line would close the task on the page. The graph records where a line cites an entry and never what the line says, and the Studio reads only the graph and its named inputs, so one of them has to hold the words. The extractor already reads the line when it records the citation, which makes the citation row the cheapest honest home; the choice is the owner's, because it puts source text in the graph for the first time. +A reader checking a pinned declaration reaches the test line that cites it and then follows a source link to read the assertion. The words of that line would close the task on the page. The graph records where a line cites an entry and never what the line says, and the Studio reads only the graph and its named inputs, so one of them has to hold the words. The extractor already reads the line when it records the citation, which makes the citation row the cheapest honest home; the owner chose it, though it puts source text in the graph for the first time. ## Intent @@ -21,17 +21,13 @@ A reader checking a pinned declaration reaches the test line that cites it and t - problem: The declarations page names each citing line by file and line only, so the reader leaves the page to read it, and has no route at all when the page set carries no source link. - outcome: Show beside each pinned declaration the text of every test line that cites it, so a reviewer reads the check without leaving the page. -### Open questions - -- [blocking #lineText] May the Studio show source text the graph does not hold, and if so is it recorded in the graph beside the citation or read as a named input? - - decider: owner - - provisional: the extractor records the citing line's text, trimmed and bounded, on the citation row it already writes, as inferred data that confers nothing, and the Studio reads no source -- [non-blocking #lineBound] How long may a recorded line be, and how does a page show a line that was cut? - - decider: delegated - - provisional: a bound of a few hundred code units, cut at a word boundary and marked as cut, the whole line one source link away - ## Behavior - rule: [#lineShown] Beside each citing line, the declarations page and the Spec page show the line's words as code after its file and line, and the reader's citation row carries them. - rule: [#wordsNotVerdict] The words are what the citing line said at the commit the graph was derived from; no page says the line asserts, checks or passes anything because of them. - rule: [#withoutWords] A citation whose words are not recorded shows its file and line as it does now. +- rule: [#lineRecorded] The extractor records the citing line's text, trimmed and bounded, on the citation row it already writes, as inferred data that confers nothing; the Studio reads no source to show it. + +## Design + +- lineBound: a recorded line holds at most a few hundred code units; a longer line is cut at a word boundary and marked as cut, and the whole line stays one source link away diff --git a/specs/protocol/delivery-record-cleanup.sdp.md b/specs/protocol/delivery-record-cleanup.sdp.md index 2166a8cb7..f5298ef05 100644 --- a/specs/protocol/delivery-record-cleanup.sdp.md +++ b/specs/protocol/delivery-record-cleanup.sdp.md @@ -22,4 +22,8 @@ Since the roadmap and each pull request carry the Protocol's delivery record, `p - [blocking #durableContent] Which content in `plans/`, `reviews/` and the concept documents does no Spec, test, decision or recipe carry yet, and where does each piece belong? - decider: owner - - provisional: a measured inventory by the arc that removes them, reviewed before anything is deleted + - provisional: the measured inventory this Spec's rule requires answers it once the owner has reviewed it; each piece then moves to its home and the question leaves the Spec + +## Behavior + +- rule: [#inventoryFirst] The arc that removes them opens with a measured inventory, in its first commit, of what `plans/`, `reviews/` and the concept documents still carry that no Spec, test, decision or recipe carries, each piece with its home; a review by another model family checks the inventory before anything is deleted. diff --git a/test/self-hosting-oracle/consumers.ts b/test/self-hosting-oracle/consumers.ts index 1f3712b76..560768bd9 100644 --- a/test/self-hosting-oracle/consumers.ts +++ b/test/self-hosting-oracle/consumers.ts @@ -49,7 +49,7 @@ export const consumersSpecs = [ catalogIndex: "the catalog opens with a table of every recipe, its number, name, parameters and a one-line use, before the contract; the build writes one file per body and none for the index.", wrapIdiom: - "to filter a shipped answer, the caller wraps the shipped body in an async arrow function that takes `params`, inside its own body, `const answer = await (async (params) => { })({ });`, and returns a filtered value, so the caller's own parameters never reach the recipe as names it does not take; the catalog shows the shell form and states that the caller's part must hold no shell expansion.", + "to filter a shipped answer, the caller calls the recipe through the `recipes` binding with only the recipe's own parameters, `const answer = await recipes(, { });`, inside its own body, and returns a filtered value, so the caller's own parameters never reach the recipe as names it does not take and the shipped body is never inlined into the caller's; the catalog shows the shell form and states that the caller's part must hold no shell expansion.", }, }, deliveryFacts: ["implemented", "has-verifier"], @@ -417,7 +417,8 @@ export const consumersSpecs = [ behavior: { rules: [ "`createReader` constructs a fresh thin typed loader that indexes graph joins and claims and computes delivery facts and validation findings once, derives readiness when a caller asks, and returns plain composable data without persisting state.", - "The reader exposes `graph`, the raw graph it reads, and the methods `specs`, `packs`, `findings`, `findByConcept`, `byFile`, `blastRadius`, `specContext` and `packContext`; a join is added only when a second machine consumer needs it and hand-rolled attempts get it wrong.", + "The reader exposes `graph`, the raw graph it reads, and the methods `specs`, `packs`, `findings`, `findByConcept`, `byFile`, `blastRadius`, `resolveEntryAddress`, `specContext` and `packContext`; a join is added only when a second machine consumer needs it and hand-rolled attempts get it wrong.", + "`resolveEntryAddress` resolves one entry address with the grammar and entry rules that prose validation and entry citations use, and returns `{ entry, resolves, reason }`: `entry` names the resolved entry, such as `rule[2]`, `question[0]` or `design.wrapIdiom`, or is null; `reason` is null when the address resolves and otherwise `malformed`, `spec`, `section` or `entry`; recipe 51 and the vault projection are its machine consumers.", "`findByConcept` matches ids, titles, anchor labels, Pack framing, Spec narrative, and reified section content, including model term keys, and names the fields a node matched on rather than returning a bare hit.", "`byFile` answers with the nodes the graph records at the path and with the Specs those nodes reach, so a source file carrying a binding names the Spec it binds and a carrier file names the Spec authored in it.", "`blastRadius` maps changed files to Specs and Packs authored at them and Specs reached through `satisfies`, `references`, `verifies` or a usable oracle `models` binding, plus their explicit one-hop at-risk neighbors; it reports every file the graph records nothing at as `coverageUnknown` and every changed code unit with no Spec linkage as `unlinked`, and neither list claims exhaustive coverage.", @@ -450,10 +451,11 @@ export const consumersSpecs = [ ], }, ruleKeys: { - conceptEntry: "rules[2]", - fileEntry: "rules[3]", - blastRadius: "rules[4]", - reasonsAsData: "rules[5]", + entryAddressResolution: "rules[2]", + conceptEntry: "rules[3]", + fileEntry: "rules[4]", + blastRadius: "rules[5]", + reasonsAsData: "rules[6]", }, }, }, @@ -7564,7 +7566,7 @@ export const consumersSpecs = [ readiness: "scoped", title: "A pinned declaration shows the words of each test line that cites it", narrative: - "A reader checking a pinned declaration reaches the test line that cites it and then follows a source link to read the assertion. The words of that line would close the task on the page. The graph records where a line cites an entry and never what the line says, and the Studio reads only the graph and its named inputs, so one of them has to hold the words. The extractor already reads the line when it records the citation, which makes the citation row the cheapest honest home; the choice is the owner's, because it puts source text in the graph for the first time.", + "A reader checking a pinned declaration reaches the test line that cites it and then follows a source link to read the assertion. The words of that line would close the task on the page. The graph records where a line cites an entry and never what the line says, and the Studio reads only the graph and its named inputs, so one of them has to hold the words. The extractor already reads the line when it records the citation, which makes the citation row the cheapest honest home; the owner chose it, though it puts source text in the graph for the first time.", sections: { intent: { actor: @@ -7573,39 +7575,25 @@ export const consumersSpecs = [ "The declarations page names each citing line by file and line only, so the reader leaves the page to read it, and has no route at all when the page set carries no source link.", outcome: "Show beside each pinned declaration the text of every test line that cites it, so a reviewer reads the check without leaving the page.", - openQuestions: [ - { - question: - "May the Studio show source text the graph does not hold, and if so is it recorded in the graph beside the citation or read as a named input?", - blocking: true, - key: "lineText", - decider: "owner", - provisional: - "the extractor records the citing line's text, trimmed and bounded, on the citation row it already writes, as inferred data that confers nothing, and the Studio reads no source", - }, - { - question: - "How long may a recorded line be, and how does a page show a line that was cut?", - blocking: false, - key: "lineBound", - decider: "delegated", - provisional: - "a bound of a few hundred code units, cut at a word boundary and marked as cut, the whole line one source link away", - }, - ], }, behavior: { rules: [ "Beside each citing line, the declarations page and the Spec page show the line's words as code after its file and line, and the reader's citation row carries them.", "The words are what the citing line said at the commit the graph was derived from; no page says the line asserts, checks or passes anything because of them.", "A citation whose words are not recorded shows its file and line as it does now.", + "The extractor records the citing line's text, trimmed and bounded, on the citation row it already writes, as inferred data that confers nothing; the Studio reads no source to show it.", ], ruleKeys: { lineShown: "rules[0]", wordsNotVerdict: "rules[1]", withoutWords: "rules[2]", + lineRecorded: "rules[3]", }, }, + design: { + lineBound: + "a recorded line holds at most a few hundred code units; a longer line is cut at a word boundary and marked as cut, and the whole line stays one source link away", + }, }, deliveryFacts: [], file: "specs/consumers/spec-studio.cited-line-text.sdp.md", diff --git a/test/self-hosting-oracle/protocol.ts b/test/self-hosting-oracle/protocol.ts index c2e59f75b..ec620e5e6 100644 --- a/test/self-hosting-oracle/protocol.ts +++ b/test/self-hosting-oracle/protocol.ts @@ -330,10 +330,18 @@ export const protocolSpecs = [ key: "durableContent", decider: "owner", provisional: - "a measured inventory by the arc that removes them, reviewed before anything is deleted", + "the measured inventory this Spec's rule requires answers it once the owner has reviewed it; each piece then moves to its home and the question leaves the Spec", }, ], }, + behavior: { + rules: [ + "The arc that removes them opens with a measured inventory, in its first commit, of what `plans/`, `reviews/` and the concept documents still carry that no Spec, test, decision or recipe carries, each piece with its home; a review by another model family checks the inventory before anything is deleted.", + ], + ruleKeys: { + inventoryFirst: "rules[0]", + }, + }, }, deliveryFacts: [], }, From 47d53827ccbeeb70eba752dded3e81ea0d5ab332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Tue, 3 Nov 2026 19:19:22 +0100 Subject: [PATCH 07/16] specs: capture the review's deferred questions and settle the ruled ones Q1: no symbolicLinks question; the owner ruled it out of the corpus Q2: JS-F1 hands scoped intent to an agent that picks its own budget (recipe 36) Q3: the AI-assisted reading capture keeps the four ruled links, each a reasoned relation Q4: the bound-file freshness trigger is the owner's wording; the owner decides Q5: #perSlotOnly drops its deferral clause; the combination capture carries it Q6: no handOffPreview question; the label and memo preview go to another lane K1: spec:consumers.ai-assisted-reading, an idea capture with a blocking deferral K2: spec:observation.run-evidence.bound-file-freshness takes #freshness off the ready parent K3: spec:consumers.agent-surface.combination-coverage, an idea capture with a blocking deferral From the whole-repository review (PR #38). Each finding was verified by a second model family, and this lane's diff was reviewed by the other. Co-Authored-By: Claude Opus 5.5 --- jtbd-stories/06-edit-through-the-lens.md | 6 +- .../agent-surface.combination-coverage.sdp.md | 22 ++++++ ...gent-surface.example-space-coverage.sdp.md | 2 +- specs/consumers/ai-assisted-reading.sdp.md | 33 +++++++++ .../run-evidence.bound-file-freshness.sdp.md | 22 ++++++ specs/observation/run-evidence.sdp.md | 7 +- specs/self-hosting.pack.sdp.md | 3 + test/self-hosting-graph.test.ts | 12 ++-- test/self-hosting-oracle/consumers.ts | 67 ++++++++++++++++++- .../self-hosting-oracle/declared-relations.ts | 34 ++++++++++ test/self-hosting-oracle/observation.ts | 43 ++++++++---- test/self-hosting-oracle/pack-members.ts | 3 + 12 files changed, 225 insertions(+), 29 deletions(-) create mode 100644 specs/consumers/agent-surface.combination-coverage.sdp.md create mode 100644 specs/consumers/ai-assisted-reading.sdp.md create mode 100644 specs/observation/run-evidence.bound-file-freshness.sdp.md diff --git a/jtbd-stories/06-edit-through-the-lens.md b/jtbd-stories/06-edit-through-the-lens.md index 80d2a1563..9cf610228 100644 --- a/jtbd-stories/06-edit-through-the-lens.md +++ b/jtbd-stories/06-edit-through-the-lens.md @@ -10,15 +10,15 @@ The view is a lens, not an editor. The job here is to let people drive a change **Phase:** Iterate *(the underlying edit model — agent edits source → git → conformance checks — is MVP-true; the in-view composer is the Iterate layer)* **References:** [06 — Consumers & Projections](../docs/concept/06-consumers-and-projections.md) -> **When** I'm exploring a spec in the view and want to change it — add an example, tighten a target, resolve a question — **I want to** select the scope and state my intent so the view hands a clean, token-budgeted prompt to an AI agent that edits the source, **so I can** edit comfortably while the repo stays canonical and conformance checks stay the gate. +> **When** I'm exploring a spec in the view and want to change it — add an example, tighten a target, resolve a question — **I want to** select the scope and state my intent so the view hands that scoped intent to an AI agent, which reads the scope within a budget it chooses (recipe 36) and edits the source, **so I can** edit comfortably while the repo stays canonical and conformance checks stay the gate. **Essence:** A view never writes to canonical source. The editor is an agent editing source — identical in kind to a human editing source — so there is no derived layer writing back, no patch format, no speculative apply. Every edit is an ordinary commit; conformance + honesty checks are the gate. **Acceptance criteria:** -1. The view's only output is scoped, token-budgeted intent: the user picks a scope (parent / siblings / a slice / open questions) and states the change — **scoped intent** — and the view composes the prompt (**intent composition**); it writes nothing to the repo itself. +1. The view's only output is scoped intent: the user picks a scope (parent / siblings / a slice / open questions) and states the change — **scoped intent** — and the view composes the hand-off (**intent composition**); the receiving agent chooses its own budget and reads that scope through recipe 36. The view writes nothing to the repo itself. 2. An AI agent via CLI applies the change by editing spec files and code, exactly as a human editing source would. 3. The change lands as an ordinary git commit, reviewable as a normal diff — there is no structured-patch subsystem, no codemod-from-view, and no in-memory patch-validator. 4. Conformance + honesty checks (`sdp validate` / CI) are the gate every edit passes — the same gate for an agent-applied edit, a human edit, and any other commit; nothing bypasses it. 5. Lifecycle operations — split, combine, refine, delete — are plain git + edit (`git mv`, edit in place, `git rm`); maturity and group-readiness signals are computed from the graph, not stored. 6. After the commit, regenerating the graph and view reflects the change, closing the loop with no manual sync. -7. The scope preview shows what the agent will be given (the in-context specs/relations and the token budget), so the human can tighten scope before handing it off. +7. The panel shows the exact hand-off before it leaves; the agent reads that scope with a budgeted context bundle. diff --git a/specs/consumers/agent-surface.combination-coverage.sdp.md b/specs/consumers/agent-surface.combination-coverage.sdp.md new file mode 100644 index 000000000..22ed235c5 --- /dev/null +++ b/specs/consumers/agent-surface.combination-coverage.sdp.md @@ -0,0 +1,22 @@ +--- +id: spec:consumers.agent-surface.combination-coverage +kind: behavior +altitude: story +readiness: idea +relations: + refines: spec:consumers.agent-surface.example-space-coverage +--- +# Which slot-value combinations example-space coverage reports + +## Intent + +- outcome: Report the combinations of slot values no example exercises, once a design pass needs them. + +### Open questions +- [blocking #combinationCoverage] Which combinations of slot values should coverage enumerate and report? + - decider: owner + - provisional: keep recipe 44 limited to individual slot values + - links: spec:consumers.agent-surface.example-space-coverage#rule.perSlotOnly + - trigger: a design pass asks which combinations no example exercises + +## Behavior diff --git a/specs/consumers/agent-surface.example-space-coverage.sdp.md b/specs/consumers/agent-surface.example-space-coverage.sdp.md index 6059d3d6d..6f065c9b3 100644 --- a/specs/consumers/agent-surface.example-space-coverage.sdp.md +++ b/specs/consumers/agent-surface.example-space-coverage.sdp.md @@ -22,7 +22,7 @@ The original design rendered a coverage heatmap from each example space: which c - rule: [#decodedOnce] The engine decodes each Spec's example space into its typed slots, and each example child's steps into its bound point, once, through the notation module that writes the space contracts, and the reader carries both on the Spec context; no recipe or projection parses slot notation a second time. - rule: [#catalogRecipe] Slot-value coverage is a catalog recipe, recipe 44, over the decoded reader values, listing each literal-union value with the example children that bind it and each value no child binds. - rule: [#literalUnions] The recipe enumerates slots typed as closed unions of quoted literals. It reports every other slot type, a boolean included, as not enumerable by this recipe, never as covered or as missing a value. -- rule: [#perSlotOnly] Coverage is reported per slot value; the recipe reports no combination of two or more slots until a design pass asks which combinations no example exercises. +- rule: [#perSlotOnly] Coverage is reported per slot value; the recipe reports no combination of two or more slots. - rule: [#eligibleChildren] The examples counted are the children its space contract collects. - rule: [#unboundSlot] An example that binds no value for a slot is listed under that slot as unbound, never as covering a value of it. - rule: [#confersNothing] Slot-value coverage reports and confers nothing: it moves no readiness, records no verdict and emits no validation finding. diff --git a/specs/consumers/ai-assisted-reading.sdp.md b/specs/consumers/ai-assisted-reading.sdp.md new file mode 100644 index 000000000..151489926 --- /dev/null +++ b/specs/consumers/ai-assisted-reading.sdp.md @@ -0,0 +1,33 @@ +--- +id: spec:consumers.ai-assisted-reading +kind: behavior +altitude: story +readiness: idea +relations: + refines: + target: spec:consumers.projections-model + reason: the boundary is drawn against the projection law, a pure function of the graph + dependsOn: + - target: spec:extraction.claim-taxonomy + reason: each graph claim carries one of the three claim categories + - target: spec:consumers.spec-studio + reason: the Studio reads only the graph and its named inputs + - target: spec:decisions.shipped-projections-frozen + reason: the shipped projections stay frozen +--- +# A boundary for assistant-written presentation + +Every reading the Protocol ships is a deterministic function of the graph and the inputs a caller names, as founding principle P3, deterministic derivation, requires. An assistant that only selects one of those readings keeps that property; one that writes explanations or arrangements of its own does not, so its output needs a contract before it reaches a reader. + +## Intent + +- outcome: Let a reader ask for a useful existing reading and inspect why it was selected and what scope it omits. + +### Open questions +- [blocking #aiPresentationBoundary] If an assistant generates explanations or arrangements beyond selecting deterministic readings, what contract keeps its output distinct from graph claims and makes the exact reading reviewable, including how the exact output is retained with its model, prompt and inputs? + - decider: owner + - provisional: Select existing deterministic readings only; any generated text stays outside the Studio and the frozen projections, labelled as assistant output with its source commit and scope, and adds no claim, fact or validator. + - links: spec:consumers.projections-model, spec:extraction.claim-taxonomy, spec:decisions.shipped-projections-frozen, spec:consumers.spec-studio#rule.namedInputs + - trigger: the UX design pass measures that assistant selection or a generated explanation helps a named review task + +## Behavior diff --git a/specs/observation/run-evidence.bound-file-freshness.sdp.md b/specs/observation/run-evidence.bound-file-freshness.sdp.md new file mode 100644 index 000000000..023d36c8d --- /dev/null +++ b/specs/observation/run-evidence.bound-file-freshness.sdp.md @@ -0,0 +1,22 @@ +--- +id: spec:observation.run-evidence.bound-file-freshness +kind: behavior +altitude: story +readiness: idea +relations: + refines: spec:observation.run-evidence +--- +# When bound-file changes make run evidence stale + +## Intent + +- outcome: Tell a reader when a recorded run no longer speaks for the source being read because the files its verifiers bind have changed. + +### Open questions +- [blocking #freshness] When should changes to bound files make recorded run evidence stale? + - decider: owner + - provisional: keep the current differing-commit display until a file-sensitive policy is designed + - links: spec:observation.run-evidence + - trigger: a reader cannot tell whether a run at another commit still applies, because nothing says whether its bound files changed since + +## Behavior diff --git a/specs/observation/run-evidence.sdp.md b/specs/observation/run-evidence.sdp.md index 91facedc1..e624f5dbd 100644 --- a/specs/observation/run-evidence.sdp.md +++ b/specs/observation/run-evidence.sdp.md @@ -18,17 +18,12 @@ relations: The original design put test reports in the graph and used passing runs to mark verification. The Protocol keeps run verdicts in CI. A resolving verifier binding confers `has-verifier`; showing a recorded run beside that binding confers no delivery fact. Runtime observation against a measurable target is a separate concern carried by the runtime overlay. -An adopter that keeps CI run reports needs to read them beside verifier bindings as a separately named input. Recipe 45 reads them, and the Studio admits them as one of its two named inputs. The view identifies each run by its own fields and its commit, and distinguishes evidence from a test file from a verdict for one anchored verifier. Test reporters write a test's file as an absolute path or a path from the directory they ran in, so the join reads a path's end as well as the whole path. The freshness question records what remains unsettled about comparing a run with the source being read. +An adopter that keeps CI run reports needs to read them beside verifier bindings as a separately named input. Recipe 45 reads them, and the Studio admits them as one of its two named inputs. The view identifies each run by its own fields and its commit, and distinguishes evidence from a test file from a verdict for one anchored verifier. Test reporters write a test's file as an absolute path or a path from the directory they ran in, so the join reads a path's end as well as the whole path. Whether changes to bound files also make a run stale waits on the bound-file freshness capture, `spec:observation.run-evidence.bound-file-freshness`. ## Intent - problem: A reader who wants to know whether a bound verifier passed has to leave the Protocol's views for CI, and an adopter that keeps run records has no view that shows them beside the binding without mistaking them for a delivery fact. - outcome: Show a verifier's recorded runs beside its binding in a view, labelled as external input with its commit, while the graph keeps recording only that the binding exists. -### Open questions -- [non-blocking #freshness] How does a view say a run is stale: by its commit against the commit being read, or by whether the bound files changed between the two? - - decider: owner - - provisional: Display the run and source commit identifiers when they differ, as the differing-commit rule requires; whether freshness also follows changes to bound files remains open. - ## Behavior - rule: [#graphUntouched] Run records are an input a view reads beside the graph, named by the caller; the graph never holds them, and no node, edge or delivery fact derives from them. - rule: [#fileGrain] A run record joins every verifier whose test anchor sits in the test file the record names, because the file is the binding grain of both anchor forms; each test name in the record shows as a row under that verifier. The join is at file grain: a result is evidence from the verifier's file, never the result of that verifier, because a file may hold several suites and a filtered run may exercise only one. diff --git a/specs/self-hosting.pack.sdp.md b/specs/self-hosting.pack.sdp.md index 446395e45..316f95014 100644 --- a/specs/self-hosting.pack.sdp.md +++ b/specs/self-hosting.pack.sdp.md @@ -206,6 +206,7 @@ specs: - spec:validation.external-registers.uncited-on-request - spec:consumers.projections-model - spec:consumers.projections-model.roadmap-views + - spec:consumers.ai-assisted-reading - spec:consumers.impact-graph - spec:consumers.impact-graph.curation-assist - spec:consumers.impact-graph.symbol-reach @@ -312,6 +313,7 @@ specs: - spec:consumers.agent-surface.example-space-coverage - spec:consumers.agent-surface.example-space-coverage.uncovered-literal - spec:consumers.agent-surface.example-space-coverage.number-not-enumerable + - spec:consumers.agent-surface.combination-coverage - spec:consumers.review-perspectives - spec:consumers.context-bundle - spec:consumers.context-bundle.parts-cover-the-pack @@ -403,6 +405,7 @@ specs: - spec:observation.run-evidence - spec:observation.run-evidence.absolute-path-joins - spec:observation.run-evidence.unanchored-file-unjoined + - spec:observation.run-evidence.bound-file-freshness - spec:protocol.self-hosting - spec:protocol.structural-self-binding - spec:protocol.significant-units-review diff --git a/test/self-hosting-graph.test.ts b/test/self-hosting-graph.test.ts index df40b81ce..f8e8f82e5 100644 --- a/test/self-hosting-graph.test.ts +++ b/test/self-hosting-graph.test.ts @@ -358,13 +358,13 @@ describe("the self-hosting corpus", () => { // The literals are the corpus checkpoint. The authored arrays are measured against the same // literals rather than standing in for them, so a transcription slip in an oracle module // cannot certify itself by moving both sides of a comparison at once. - expect(result.counts).toEqual({ specs: 456, packs: 9, anchors: 607 }); - expect(expectedSpecs).toHaveLength(456); - expect(expectedPackMembers).toHaveLength(456); + expect(result.counts).toEqual({ specs: 459, packs: 9, anchors: 607 }); + expect(expectedSpecs).toHaveLength(459); + expect(expectedPackMembers).toHaveLength(459); expect(expectedAnchors).toHaveLength(607); expect(expectedBindingEdges).toHaveLength(810); - expect(result.graph.nodes).toHaveLength(1072); - expect(result.graph.edges).toHaveLength(2978); + expect(result.graph.nodes).toHaveLength(1075); + expect(result.graph.edges).toHaveLength(2987); }); it("permits a named second diagram view in the authored element-id contract", () => { @@ -448,7 +448,7 @@ describe("the self-hosting corpus", () => { }), {}, ), - ).toEqual({ idea: 5, ready: 447, scoped: 4 }); + ).toEqual({ idea: 8, ready: 447, scoped: 4 }); }); it("derives the Pack membership edges from the manifest, in manifest order", () => { diff --git a/test/self-hosting-oracle/consumers.ts b/test/self-hosting-oracle/consumers.ts index 560768bd9..e4a4bdada 100644 --- a/test/self-hosting-oracle/consumers.ts +++ b/test/self-hosting-oracle/consumers.ts @@ -2582,7 +2582,7 @@ export const consumersSpecs = [ "The engine decodes each Spec's example space into its typed slots, and each example child's steps into its bound point, once, through the notation module that writes the space contracts, and the reader carries both on the Spec context; no recipe or projection parses slot notation a second time.", "Slot-value coverage is a catalog recipe, recipe 44, over the decoded reader values, listing each literal-union value with the example children that bind it and each value no child binds.", "The recipe enumerates slots typed as closed unions of quoted literals. It reports every other slot type, a boolean included, as not enumerable by this recipe, never as covered or as missing a value.", - "Coverage is reported per slot value; the recipe reports no combination of two or more slots until a design pass asks which combinations no example exercises.", + "Coverage is reported per slot value; the recipe reports no combination of two or more slots.", "The examples counted are the children its space contract collects.", "An example that binds no value for a slot is listed under that slot as unbound, never as covering a value of it.", "Slot-value coverage reports and confers nothing: it moves no readiness, records no verdict and emits no validation finding.", @@ -8353,4 +8353,69 @@ export const consumersSpecs = [ }, deliveryFacts: ["has-verifier"], }, + { + id: "spec:consumers.ai-assisted-reading", + specKind: "behavior", + altitude: "story", + readiness: "idea", + file: "specs/consumers/ai-assisted-reading.sdp.md", + title: "A boundary for assistant-written presentation", + narrative: + "Every reading the Protocol ships is a deterministic function of the graph and the inputs a caller names, as founding principle P3, deterministic derivation, requires. An assistant that only selects one of those readings keeps that property; one that writes explanations or arrangements of its own does not, so its output needs a contract before it reaches a reader.", + sections: { + intent: { + outcome: + "Let a reader ask for a useful existing reading and inspect why it was selected and what scope it omits.", + openQuestions: [ + { + question: + "If an assistant generates explanations or arrangements beyond selecting deterministic readings, what contract keeps its output distinct from graph claims and makes the exact reading reviewable, including how the exact output is retained with its model, prompt and inputs?", + blocking: true, + key: "aiPresentationBoundary", + decider: "owner", + provisional: + "Select existing deterministic readings only; any generated text stays outside the Studio and the frozen projections, labelled as assistant output with its source commit and scope, and adds no claim, fact or validator.", + links: [ + "spec:consumers.projections-model", + "spec:extraction.claim-taxonomy", + "spec:decisions.shipped-projections-frozen", + "spec:consumers.spec-studio#rule.namedInputs", + ], + trigger: + "the UX design pass measures that assistant selection or a generated explanation helps a named review task", + }, + ], + }, + behavior: {}, + }, + deliveryFacts: [], + }, + { + id: "spec:consumers.agent-surface.combination-coverage", + specKind: "behavior", + altitude: "story", + readiness: "idea", + file: "specs/consumers/agent-surface.combination-coverage.sdp.md", + title: "Which slot-value combinations example-space coverage reports", + narrative: null, + sections: { + intent: { + outcome: + "Report the combinations of slot values no example exercises, once a design pass needs them.", + openQuestions: [ + { + question: "Which combinations of slot values should coverage enumerate and report?", + blocking: true, + key: "combinationCoverage", + decider: "owner", + provisional: "keep recipe 44 limited to individual slot values", + links: ["spec:consumers.agent-surface.example-space-coverage#rule.perSlotOnly"], + trigger: "a design pass asks which combinations no example exercises", + }, + ], + }, + behavior: {}, + }, + deliveryFacts: [], + }, ] as const; diff --git a/test/self-hosting-oracle/declared-relations.ts b/test/self-hosting-oracle/declared-relations.ts index 2a6a8564b..510f3fea5 100644 --- a/test/self-hosting-oracle/declared-relations.ts +++ b/test/self-hosting-oracle/declared-relations.ts @@ -4872,4 +4872,38 @@ export const expectedDeclaredRelations: readonly (readonly [ "spec:consumers.spec-studio.diagram-kinds.requirement-trace", "the Design view reads the selected set and claims the requirement drawing defines", ], + [ + "spec:observation.run-evidence.bound-file-freshness", + "refines", + "spec:observation.run-evidence", + ], + [ + "spec:consumers.agent-surface.combination-coverage", + "refines", + "spec:consumers.agent-surface.example-space-coverage", + ], + [ + "spec:consumers.ai-assisted-reading", + "refines", + "spec:consumers.projections-model", + "the boundary is drawn against the projection law, a pure function of the graph", + ], + [ + "spec:consumers.ai-assisted-reading", + "dependsOn", + "spec:extraction.claim-taxonomy", + "each graph claim carries one of the three claim categories", + ], + [ + "spec:consumers.ai-assisted-reading", + "dependsOn", + "spec:consumers.spec-studio", + "the Studio reads only the graph and its named inputs", + ], + [ + "spec:consumers.ai-assisted-reading", + "dependsOn", + "spec:decisions.shipped-projections-frozen", + "the shipped projections stay frozen", + ], ] as const; diff --git a/test/self-hosting-oracle/observation.ts b/test/self-hosting-oracle/observation.ts index 172910e1f..69530e510 100644 --- a/test/self-hosting-oracle/observation.ts +++ b/test/self-hosting-oracle/observation.ts @@ -37,24 +37,13 @@ export const observationSpecs = [ file: "specs/observation/run-evidence.sdp.md", title: "CI run records are joined to verifiers in a view", narrative: - "The original design put test reports in the graph and used passing runs to mark verification. The Protocol keeps run verdicts in CI. A resolving verifier binding confers `has-verifier`; showing a recorded run beside that binding confers no delivery fact. Runtime observation against a measurable target is a separate concern carried by the runtime overlay.\n\nAn adopter that keeps CI run reports needs to read them beside verifier bindings as a separately named input. Recipe 45 reads them, and the Studio admits them as one of its two named inputs. The view identifies each run by its own fields and its commit, and distinguishes evidence from a test file from a verdict for one anchored verifier. Test reporters write a test's file as an absolute path or a path from the directory they ran in, so the join reads a path's end as well as the whole path. The freshness question records what remains unsettled about comparing a run with the source being read.", + "The original design put test reports in the graph and used passing runs to mark verification. The Protocol keeps run verdicts in CI. A resolving verifier binding confers `has-verifier`; showing a recorded run beside that binding confers no delivery fact. Runtime observation against a measurable target is a separate concern carried by the runtime overlay.\n\nAn adopter that keeps CI run reports needs to read them beside verifier bindings as a separately named input. Recipe 45 reads them, and the Studio admits them as one of its two named inputs. The view identifies each run by its own fields and its commit, and distinguishes evidence from a test file from a verdict for one anchored verifier. Test reporters write a test's file as an absolute path or a path from the directory they ran in, so the join reads a path's end as well as the whole path. Whether changes to bound files also make a run stale waits on the bound-file freshness capture, `spec:observation.run-evidence.bound-file-freshness`.", sections: { intent: { problem: "A reader who wants to know whether a bound verifier passed has to leave the Protocol's views for CI, and an adopter that keeps run records has no view that shows them beside the binding without mistaking them for a delivery fact.", outcome: "Show a verifier's recorded runs beside its binding in a view, labelled as external input with its commit, while the graph keeps recording only that the binding exists.", - openQuestions: [ - { - question: - "How does a view say a run is stale: by its commit against the commit being read, or by whether the bound files changed between the two?", - blocking: false, - key: "freshness", - decider: "owner", - provisional: - "Display the run and source commit identifiers when they differ, as the differing-commit rule requires; whether freshness also follows changes to bound files remains open.", - }, - ], }, behavior: { rules: [ @@ -178,4 +167,34 @@ export const observationSpecs = [ }, deliveryFacts: ["has-verifier"], }, + { + id: "spec:observation.run-evidence.bound-file-freshness", + specKind: "behavior", + altitude: "story", + readiness: "idea", + file: "specs/observation/run-evidence.bound-file-freshness.sdp.md", + title: "When bound-file changes make run evidence stale", + narrative: null, + sections: { + intent: { + outcome: + "Tell a reader when a recorded run no longer speaks for the source being read because the files its verifiers bind have changed.", + openQuestions: [ + { + question: "When should changes to bound files make recorded run evidence stale?", + blocking: true, + key: "freshness", + decider: "owner", + provisional: + "keep the current differing-commit display until a file-sensitive policy is designed", + links: ["spec:observation.run-evidence"], + trigger: + "a reader cannot tell whether a run at another commit still applies, because nothing says whether its bound files changed since", + }, + ], + }, + behavior: {}, + }, + deliveryFacts: [], + }, ] as const; diff --git a/test/self-hosting-oracle/pack-members.ts b/test/self-hosting-oracle/pack-members.ts index 45206d6c1..e568ff1c5 100644 --- a/test/self-hosting-oracle/pack-members.ts +++ b/test/self-hosting-oracle/pack-members.ts @@ -207,6 +207,7 @@ export const expectedPackMembers = [ "spec:validation.external-registers.uncited-on-request", "spec:consumers.projections-model", "spec:consumers.projections-model.roadmap-views", + "spec:consumers.ai-assisted-reading", "spec:consumers.impact-graph", "spec:consumers.impact-graph.curation-assist", "spec:consumers.impact-graph.symbol-reach", @@ -313,6 +314,7 @@ export const expectedPackMembers = [ "spec:consumers.agent-surface.example-space-coverage", "spec:consumers.agent-surface.example-space-coverage.uncovered-literal", "spec:consumers.agent-surface.example-space-coverage.number-not-enumerable", + "spec:consumers.agent-surface.combination-coverage", "spec:consumers.review-perspectives", "spec:consumers.context-bundle", "spec:consumers.context-bundle.parts-cover-the-pack", @@ -404,6 +406,7 @@ export const expectedPackMembers = [ "spec:observation.run-evidence", "spec:observation.run-evidence.absolute-path-joins", "spec:observation.run-evidence.unanchored-file-unjoined", + "spec:observation.run-evidence.bound-file-freshness", "spec:protocol.self-hosting", "spec:protocol.structural-self-binding", "spec:protocol.significant-units-review", From 292fb23da4ba33200fc8d6d50ee5df797a58dcf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Tue, 3 Nov 2026 19:27:07 +0100 Subject: [PATCH 08/16] fix(cli): keep writes inside the root and stop discovery following links L4-01: stage build outputs with exclusive creation after unlinking stale entries, and refuse an unsafe or dangling generated parent before any write or cleanup. L4-04: resolve only the watch root's physical path, so validate, Studio and vault watch run through a linked root while output keeps the named path. L4-02: refuse view, mermaid and gherkin publication when two page paths collide by case, naming both paths and IDs. Q1: discovery never follows a symbolic link below the root, warns once for each link outside an excluded or skipped path, and resolves an explicitly named root first; the excludes Spec and oracle pins follow the owner's ruling. From the whole-repository review (PR #38). Each finding was verified by a second model family, and this lane's diff was reviewed by the other. Co-Authored-By: Claude Opus 5.5 --- specs/extraction/excludes.sdp.md | 2 +- src/cli/artifacts.ts | 48 ++++- src/cli/build-args.ts | 12 +- src/cli/build-command.ts | 63 ++++--- src/cli/gherkin-command.ts | 3 +- src/cli/mermaid-command.ts | 3 +- src/cli/validate-view-command.ts | 3 +- src/cli/validate-watch.ts | 5 +- src/extract/discover.ts | 13 +- src/extract/index.ts | 8 +- src/extract/reify.ts | 1 + test/anchor-trust.test.ts | 10 +- test/cli-write-boundaries.test.ts | 234 +++++++++++++++++++++++++ test/exclude-diagnostics.test.ts | 52 +++++- test/helpers/validator-audit.ts | 19 +- test/helpers/validator-probes.ts | 5 + test/iteration-recipes.test.ts | 13 +- test/recipes.test.ts | 32 +++- test/self-hosting-graph.test.ts | 13 +- test/self-hosting-model.test.ts | 11 +- test/self-hosting-oracle/extraction.ts | 2 +- 21 files changed, 500 insertions(+), 52 deletions(-) create mode 100644 test/cli-write-boundaries.test.ts diff --git a/specs/extraction/excludes.sdp.md b/specs/extraction/excludes.sdp.md index fbdf66823..7309fe60e 100644 --- a/specs/extraction/excludes.sdp.md +++ b/specs/extraction/excludes.sdp.md @@ -13,7 +13,7 @@ relations: - outcome: Keep consumer-selected omissions precise without changing the extractor's canonical discovery rules. ## Rule -- [#canonicalDiscovery] Discovery reads Spec and Pack carriers by the suffixes `.sdp.md`, `.sdp.gherkin` and `.sdp.ts`. Other `.ts` and `.tsx` files are anchor candidates, except `.d.ts` files. Discovery never descends into `node_modules`, `dist`, `generated`, `coverage` or a dot-directory, and configured exclusions apply to both lists. +- [#canonicalDiscovery] Discovery reads Spec and Pack carriers by the suffixes `.sdp.md`, `.sdp.gherkin` and `.sdp.ts`. Other `.ts` and `.tsx` files are anchor candidates, except `.d.ts` files. Discovery never descends into `node_modules`, `dist`, `generated`, `coverage` or a dot-directory, and configured exclusions apply to both lists. Discovery never follows a symbolic link below the root; each link found outside an excluded or skipped path gets one warning naming it; a root named explicitly is resolved to its real directory first. - An exclusion is an exact root-relative POSIX path prefix applied to Spec-carrier and anchor-candidate discovery; a repeated exclusion counts once. - [#segmentBoundary] A prefix excludes itself and slash-delimited descendants only; it never excludes a merely similar sibling path. - [#refusedPaths] Empty, absolute, Windows-drive, backslash and trailing-slash paths, and paths with an empty, `.` or `..` segment, are refused rather than normalized into a different meaning. diff --git a/src/cli/artifacts.ts b/src/cli/artifacts.ts index 3f69723ba..b57ee7d45 100644 --- a/src/cli/artifacts.ts +++ b/src/cli/artifacts.ts @@ -1,4 +1,5 @@ -import { rmSync } from "node:fs"; +import { lstatSync, realpathSync, rmSync } from "node:fs"; +import { dirname, isAbsolute, relative, sep } from "node:path"; import type { CliOutput } from "./output.js"; import { errorMessage, writeStderr } from "./output.js"; @@ -31,3 +32,48 @@ export function removeArtifacts( } } } + +/** Check the physical parent before touching an owned output, including during recovery. */ +export function assertOutputParent(root: string, path: string): void { + let parent = dirname(path); + for (;;) { + try { + lstatSync(parent); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + const ancestor = dirname(parent); + if (ancestor === parent) throw error; + parent = ancestor; + } + } + let physicalParent: string; + try { + physicalParent = realpathSync.native(parent); + } catch { + throw new Error(`unsafe output parent "${parent}" for "${path}" cannot be resolved`); + } + const nested = relative(realpathSync.native(root), physicalParent); + if (nested === ".." || nested.startsWith(`..${sep}`) || isAbsolute(nested)) { + throw new Error(`unsafe output parent for "${path}" escapes the extraction root`); + } +} + +/** Refuse portable path collisions without changing any frozen projection's names or bytes. */ +export function assertDistinctPagePaths( + pages: readonly { readonly path: string }[], + suffix = ".md", +): void { + const paths = new Map(); + for (const { path } of pages) { + const key = path.toLowerCase(); + const prior = paths.get(key); + if (prior !== undefined) { + const id = (name: string): string => name.slice(0, -suffix.length).replace("/", ":"); + throw new Error( + `case-insensitive publication path collision: "${prior}" (${id(prior)}) and "${path}" (${id(path)})`, + ); + } + paths.set(key, path); + } +} diff --git a/src/cli/build-args.ts b/src/cli/build-args.ts index 360e92a23..66772fb82 100644 --- a/src/cli/build-args.ts +++ b/src/cli/build-args.ts @@ -1,8 +1,9 @@ +import { assertOutputParent } from "./artifacts.js"; import { readStudioBase, readStudioRuns } from "./studio-inputs.js"; import type { StudioInputs } from "../projections/studio/inputs.js"; import { mergeParams } from "./params.js"; import { realpathSync, statSync } from "node:fs"; -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; import { InvalidExcludePathError, normalizeExcludes } from "../extract/discover.js"; import type { CliOutput } from "./output.js"; @@ -290,6 +291,15 @@ export function parseBuildArgs( const resolvedRoot = resolveExtractionRoot(root, output, command); if (resolvedRoot === undefined) return undefined; + try { + assertOutputParent(resolvedRoot, join(resolvedRoot, "generated", "graph.json")); + } catch (error) { + writeStderr( + output, + `sdp ${command}: ${error instanceof Error ? error.message : String(error)}\n`, + ); + return undefined; + } const studioInputs = base === undefined ? undefined : readStudioBase(base, baseLabel ?? null, output, resolvedRoot); if (base !== undefined && studioInputs === undefined) return undefined; diff --git a/src/cli/build-command.ts b/src/cli/build-command.ts index eb4471754..fcdac1f92 100644 --- a/src/cli/build-command.ts +++ b/src/cli/build-command.ts @@ -18,7 +18,7 @@ import type { GraphSchema } from "../graph/schema.js"; import type { ValidationReport } from "../validate/contracts.js"; import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; import { codeAnchor } from "../model/code-anchor.js"; -import { removeArtifact, removeArtifacts } from "./artifacts.js"; +import { assertOutputParent, 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"; @@ -283,22 +283,44 @@ export function runBuild( let nextRegistrarPaths: readonly string[] = []; const projectionPaths = projectionOutputs.map((path) => join(resolvedRoot, path)); + const outputParentIsSafe = (): boolean => { + try { + assertOutputParent(resolvedRoot, graphPath); + return true; + } catch (error) { + writeStderr( + output, + `sdp ${command}: ${errorMessage(error)} — outputs must not be read as current.\n`, + ); + return false; + } + }; + if (!outputParentIsSafe()) return { exitCode: 1 }; + + const stageFile = (path: string, content: string): void => { + assertOutputParent(resolvedRoot, path); + // Unlink stale entries first; exclusive creation also refuses a link planted after removal. + rmSync(path, { force: true }); + write(path, content, { encoding: "utf8", flag: "wx" }); + }; + const failBuild = (message: string, report?: ValidationReport): BuildOutcome => { writeStderr(output, message); - removeArtifacts( - [ - graphPath, - `${graphPath}.tmp`, - contractsPath, - `${contractsPath}.tmp`, - registrarManifestPath, - `${registrarManifestPath}.tmp`, - ...(parsed.imports === undefined ? [] : [importsPath, `${importsPath}.tmp`]), - ], - output, - command, - recoveryRm, - ); + if (outputParentIsSafe()) + removeArtifacts( + [ + graphPath, + `${graphPath}.tmp`, + contractsPath, + `${contractsPath}.tmp`, + registrarManifestPath, + `${registrarManifestPath}.tmp`, + ...(parsed.imports === undefined ? [] : [importsPath, `${importsPath}.tmp`]), + ], + output, + command, + recoveryRm, + ); for (const path of new Set([...priorRegistrarPaths, ...nextRegistrarPaths])) { try { @@ -448,9 +470,10 @@ export function runBuild( } const temporaryPath = `${graphPath}.tmp`; + assertOutputParent(resolvedRoot, graphPath); mkdirSync(join(resolvedRoot, "generated"), { recursive: true }); - write(temporaryPath, serialized, "utf8"); - if (imports !== undefined) write(`${importsPath}.tmp`, imports, "utf8"); + stageFile(temporaryPath, serialized); + if (imports !== undefined) stageFile(`${importsPath}.tmp`, imports); const contractsTemporaryPath = `${contractsPath}.tmp`; rmSync(contractsTemporaryPath, { recursive: true, force: true }); @@ -461,7 +484,7 @@ export function runBuild( for (const [relativePath, content] of contracts.files) { const target = join(contractsTemporaryPath, relativePath); mkdirSync(join(target, ".."), { recursive: true }); - write(target, content, "utf8"); + stageFile(target, content); } } @@ -486,12 +509,12 @@ export function runBuild( } if (existsSync(registrarPath) && readFileSync(registrarPath, "utf8") === content) continue; - write(`${registrarPath}.tmp`, content, "utf8"); + stageFile(`${registrarPath}.tmp`, content); changedRegistrarPaths.add(relativePath); } const registrarManifestTemporaryPath = `${registrarManifestPath}.tmp`; - write(registrarManifestTemporaryPath, serializeRegistrarManifest(nextRegistrarPaths), "utf8"); + stageFile(registrarManifestTemporaryPath, serializeRegistrarManifest(nextRegistrarPaths)); // Changed bytes now exist in temporary locations. Publish, reconcile, and remove stale // siblings; any later failure falls through failBuild, which removes the whole known set so diff --git a/src/cli/gherkin-command.ts b/src/cli/gherkin-command.ts index 97426af50..cb5620deb 100644 --- a/src/cli/gherkin-command.ts +++ b/src/cli/gherkin-command.ts @@ -14,7 +14,7 @@ import { codeAnchor } from "../model/code-anchor.js"; import { renderGherkinView } from "../projections/gherkin-view.js"; import type { GherkinViewPage } from "../projections/gherkin-view.js"; import { createReader } from "../reader/reader.js"; -import { removeArtifacts } from "./artifacts.js"; +import { assertDistinctPagePaths, removeArtifacts } from "./artifacts.js"; import type { BuildArgs } from "./build-args.js"; import type { BuildOutcome } from "./build-command.js"; import { errorMessage, writeStderr, writeStdout } from "./output.js"; @@ -111,6 +111,7 @@ export function prepareGherkinPublication( try { const pages = render(createReader(validate.graph)); + assertDistinctPagePaths(pages, ".feature.md"); if (parsed.checkClean) { const second = render(createReader(validate.graph)); diff --git a/src/cli/mermaid-command.ts b/src/cli/mermaid-command.ts index 3da41cb55..e6c821d68 100644 --- a/src/cli/mermaid-command.ts +++ b/src/cli/mermaid-command.ts @@ -14,7 +14,7 @@ import { codeAnchor } from "../model/code-anchor.js"; import { renderMermaid } from "../projections/mermaid.js"; import { createReader } from "../reader/reader.js"; import type { Reader } from "../reader/reader.js"; -import { removeArtifacts } from "./artifacts.js"; +import { assertDistinctPagePaths, removeArtifacts } from "./artifacts.js"; import type { BuildArgs } from "./build-args.js"; import type { BuildOutcome } from "./build-command.js"; import { errorMessage, writeStderr, writeStdout } from "./output.js"; @@ -127,6 +127,7 @@ export function prepareMermaidPublication( try { const pages = normalizePages(render(createReader(validate.graph))); + assertDistinctPagePaths(pages); if (parsed.checkClean) { const second = normalizePages(render(createReader(validate.graph))); diff --git a/src/cli/validate-view-command.ts b/src/cli/validate-view-command.ts index 62c62cb63..b669cf5d6 100644 --- a/src/cli/validate-view-command.ts +++ b/src/cli/validate-view-command.ts @@ -7,7 +7,7 @@ import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; import { codeAnchor } from "../model/code-anchor.js"; import type { CorpusValidationReport, Finding, ValidationReport } from "../validate/contracts.js"; import { validateGraph } from "../validate/validators.js"; -import { removeArtifacts } from "./artifacts.js"; +import { assertDistinctPagePaths, 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"; @@ -159,6 +159,7 @@ export function publishDesignReview( try { const pages = render(createReader(validate.graph)); + assertDistinctPagePaths(pages); if (parsed.checkClean) { const second = render(createReader(validate.graph)); diff --git a/src/cli/validate-watch.ts b/src/cli/validate-watch.ts index e54366045..9cae1d240 100644 --- a/src/cli/validate-watch.ts +++ b/src/cli/validate-watch.ts @@ -227,7 +227,10 @@ function createFilesystemWatchSource(input: { readonly lstatWatchPath?: (path: string) => Stats; }): ValidateWatchEventSource { const createNativeWatch = input.createNativeWatch ?? defaultNativeWatch; - const lstatWatchPath = input.lstatWatchPath ?? lstatSync; + // Resolve only the selected root; descendant links stay outside the watched tree. + const lstatWatchPath = + input.lstatWatchPath ?? + ((path: string) => lstatSync(path === input.root ? realpathSync.native(path) : path)); const watchers = new Map(); let eventListener: ((event: ValidateWatchEvent) => void) | undefined; let errorListener: ((error: unknown) => void) | undefined; diff --git a/src/extract/discover.ts b/src/extract/discover.ts index 698fcacee..956d7a523 100644 --- a/src/extract/discover.ts +++ b/src/extract/discover.ts @@ -1,4 +1,4 @@ -import { readdirSync } from "node:fs"; +import { readdirSync, realpathSync } from "node:fs"; import { join } from "node:path"; import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; @@ -77,12 +77,14 @@ export interface DiscoveredSourceFile { export interface DiscoveredFiles { readonly specFiles: readonly DiscoveredSourceFile[]; readonly anchorCandidateFiles: readonly DiscoveredSourceFile[]; + readonly skippedLinks: readonly string[]; } interface DiscoveryState { readonly excludes: readonly string[]; readonly specFiles: DiscoveredSourceFile[]; readonly anchorCandidateFiles: DiscoveredSourceFile[]; + readonly skippedLinks: string[]; } function compareCodeUnits(a: string, b: string): number { @@ -127,6 +129,11 @@ function walkDirectory( continue; } + if (entry.isSymbolicLink()) { + if (!isExcludedDiscoveryDirectory(entry.name)) state.skippedLinks.push(relativePath); + continue; + } + 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. @@ -168,11 +175,13 @@ export function discoverFiles(root: string, exclude?: readonly string[]): Discov excludes: normalizeExcludes(exclude), specFiles: [], anchorCandidateFiles: [], + skippedLinks: [], }; - walkDirectory(root, "", state); + walkDirectory(realpathSync.native(root), "", state); return { specFiles: state.specFiles.sort(byRelativePath), anchorCandidateFiles: state.anchorCandidateFiles.sort(byRelativePath), + skippedLinks: state.skippedLinks.sort(compareCodeUnits), }; } diff --git a/src/extract/index.ts b/src/extract/index.ts index e12b24a8f..58425f675 100644 --- a/src/extract/index.ts +++ b/src/extract/index.ts @@ -223,7 +223,13 @@ export function extract(options: ExtractOptions): ExtractionResult { const specs: ReifiedSpec[] = []; const packs: ReifiedPack[] = []; const anchors: ReifiedAnchor[] = []; - const findings: Finding[] = []; + const findings: Finding[] = files.skippedLinks.map((file) => ({ + validatorId: extractFindingIds.symbolicLinkSkipped, + family: "conformance", + severity: "warning", + message: `symbolic link "${file}" is skipped; discovery never follows links below the root`, + file, + })); const citations: EntryCitationLocation[] = []; for (const file of files.specFiles) { diff --git a/src/extract/reify.ts b/src/extract/reify.ts index 2573d1437..3eb78da87 100644 --- a/src/extract/reify.ts +++ b/src/extract/reify.ts @@ -56,6 +56,7 @@ import { setOwn } from "./set-own.js"; * file's content stays out of the graph (ambiguity is loud, L2). */ export const extractFindingIds = { + symbolicLinkSkipped: "extract/symbolic-link-skipped", parseError: "extract/parse-error", invalidMarkdownStructure: "extract/invalid-markdown-structure", unrecognizedHeading: "extract/unrecognized-heading", diff --git a/test/anchor-trust.test.ts b/test/anchor-trust.test.ts index ba2a257fd..c3a6b3ee0 100644 --- a/test/anchor-trust.test.ts +++ b/test/anchor-trust.test.ts @@ -93,7 +93,15 @@ void binding; const result = extract({ root }); - expect(result.report.findings).toEqual([]); + expect(result.report.findings).toEqual( + ["ids.ts", "model/code-anchor.ts"].map((file) => ({ + validatorId: "extract/symbolic-link-skipped", + family: "conformance", + severity: "warning", + file, + message: `symbolic link "${file}" is skipped; discovery never follows links below the root`, + })), + ); expect(result.counts.anchors).toBe(1); expect(result.graph.nodes).toContainEqual( expect.objectContaining({ id: "impl:protocol.deep-binding", nodeType: "CodeNode" }), diff --git a/test/cli-write-boundaries.test.ts b/test/cli-write-boundaries.test.ts new file mode 100644 index 000000000..880eff769 --- /dev/null +++ b/test/cli-write-boundaries.test.ts @@ -0,0 +1,234 @@ +import { + existsSync, + linkSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + 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 { createCaptureOutput } from "./helpers/cli-capture.js"; + +function writeCarrier(root: string, file: string, id: string): void { + writeFileSync( + join(root, file), + `---\nid: ${id}\nkind: rule\naltitude: story\nreadiness: idea\nrelations: {}\n---\n# Probe\n\n## Intent\n\n- outcome: Exercise publication.\n\n## Rule\n`, + ); +} + +describe("build write boundaries", () => { + it.each( + ["graph.json", "imports.json", "registrars.json"].flatMap((name) => + ["symbolic", "hard"].map((kind) => ({ name, kind })), + ), + )("replaces a stale $kind link at $name without touching its target", ({ name, kind }) => { + const workspace = mkdtempSync(join(tmpdir(), "sdp-stage-link-")); + const root = join(workspace, "corpus"); + const sentinel = join(workspace, "sentinel"); + const temporary = join(root, "generated", `${name}.tmp`); + mkdirSync(join(root, "generated"), { recursive: true }); + writeFileSync(sentinel, "unrelated bytes\n"); + writeFileSync(join(root, "source.ts"), "export const value = 1;\n"); + writeFileSync(join(root, "tsconfig.json"), '{"files":["source.ts"]}'); + writeFileSync( + join(root, "imports-config.json"), + JSON.stringify({ adapter: "typescript", tsconfig: "tsconfig.json", roots: ["."] }), + ); + if (kind === "symbolic") symlinkSync(sentinel, temporary); + else linkSync(sentinel, temporary); + try { + const capture = createCaptureOutput(); + expect( + runSdpCli(["build", root, "--imports", join(root, "imports-config.json")], capture.output), + capture.readStderr(), + ).toBe(0); + expect(readFileSync(sentinel, "utf8")).toBe("unrelated bytes\n"); + const published = join(root, "generated", name); + expect(lstatSync(published).isSymbolicLink()).toBe(false); + expect(lstatSync(published).nlink).toBe(1); + expect(existsSync(temporary)).toBe(false); + expect(() => JSON.parse(readFileSync(published, "utf8")) as unknown).not.toThrow(); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + it("names an unresolvable output parent when generated is a dangling link", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-dangling-output-")); + const parent = join(root, "generated"); + symlinkSync(join(root, "missing"), parent, "dir"); + try { + const capture = createCaptureOutput(); + expect(runSdpCli(["build", root], capture.output)).toBe(1); + expect(capture.readStderr()).toContain(`unsafe output parent "${parent}"`); + expect(capture.readStderr()).toContain("cannot be resolved"); + expect(lstatSync(parent).isSymbolicLink()).toBe(true); + expect(existsSync(join(root, "missing"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("refuses a staging link planted after stale removal", () => { + const workspace = mkdtempSync(join(tmpdir(), "sdp-stage-race-")); + const root = join(workspace, "corpus"); + const sentinel = join(workspace, "sentinel"); + mkdirSync(root); + writeFileSync(sentinel, "unrelated bytes\n"); + try { + const capture = createCaptureOutput(); + expect( + runSdpCli(["build", root], capture.output, { + writeFileSync: (path, data, options) => { + if (String(path).endsWith("graph.json.tmp")) symlinkSync(sentinel, String(path)); + writeFileSync(path, data, options); + }, + }), + ).toBe(1); + expect(readFileSync(sentinel, "utf8")).toBe("unrelated bytes\n"); + expect(capture.readStderr()).toContain("EEXIST"); + expect(existsSync(join(root, "generated", "graph.json"))).toBe(false); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + it.each(["build", "view", "mermaid", "gherkin", "studio", "generate"])( + "%s refuses an escaping generated parent before writing or cleanup", + async (command) => { + const workspace = mkdtempSync(join(tmpdir(), "sdp-output-parent-")); + const root = join(workspace, "corpus"); + const outside = join(workspace, "outside"); + mkdirSync(root); + mkdirSync(join(outside, "design-review"), { recursive: true }); + writeFileSync(join(outside, "graph.json"), "unrelated graph\n"); + writeFileSync(join(outside, "design-review", "index.md"), "unrelated page\n"); + symlinkSync(outside, join(root, "generated"), "dir"); + try { + const capture = createCaptureOutput(); + expect(await runSdpCli([command, root], capture.output)).toBe(1); + expect(capture.readStderr()).toContain("escapes the extraction root"); + expect(readFileSync(join(outside, "graph.json"), "utf8")).toBe("unrelated graph\n"); + expect(readFileSync(join(outside, "design-review", "index.md"), "utf8")).toBe( + "unrelated page\n", + ); + expect(existsSync(join(outside, "registrars.json"))).toBe(false); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }, + ); +}); + +describe("explicit symlinked watch root", () => { + it("keeps the named root in ordinary command output", () => { + const workspace = mkdtempSync(join(tmpdir(), "sdp-named-root-")); + const root = join(workspace, "corpus"); + const alias = join(workspace, "alias"); + mkdirSync(root); + symlinkSync(root, alias, "dir"); + try { + const capture = createCaptureOutput(); + expect(runSdpCli(["build", alias], capture.output)).toBe(0); + expect(capture.readStdout()).toContain(join(alias, "generated", "graph.json")); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + it.each(["validate", "studio", "vault"])( + "%s starts and reruns through the named root", + async (command) => { + const workspace = mkdtempSync(join(tmpdir(), "sdp-watch-root-link-")); + const root = join(workspace, "corpus"); + const alias = join(workspace, "alias"); + mkdirSync(root); + symlinkSync(root, alias, "dir"); + symlinkSync(root, join(root, "loop"), "dir"); + writeCarrier(root, "probe.sdp.md", "spec:probe.watch"); + const subscriptions = new Map void>(); + const abort = new AbortController(); + const capture = createCaptureOutput(); + let cycles = 0; + const timer = setTimeout(() => { + abort.abort(); + }, 2_000); + try { + const exitCode = await runSdpCli( + [ + command, + alias, + "--watch", + ...(command === "vault" ? ["--out", join(root, ".vault")] : []), + ], + capture.output, + { + abortSignal: abort.signal, + studioAssets: { + script: join(process.cwd(), "dist/studio/studio.js"), + stylesheet: join(process.cwd(), "dist/studio/studio.css"), + }, + createNativeWatch: (directory, listener) => { + subscriptions.set(directory, listener); + return { on: () => undefined, close: () => undefined }; + }, + onWatchCycleComplete: (cycle) => { + expect(cycle.exitCode, capture.readStderr()).toBe(0); + cycles += 1; + if (cycles === 1) { + writeCarrier(root, "next.sdp.md", "spec:probe.next"); + subscriptions.get(alias)?.("rename", "next.sdp.md"); + } else abort.abort(); + }, + }, + ); + expect(exitCode, capture.readStderr()).toBe(0); + expect(cycles).toBe(2); + expect([...subscriptions.keys()]).toEqual([alias]); + expect(capture.readStderr()).not.toContain("watch failed"); + expect(readFileSync(join(root, "generated", "graph.json"), "utf8")).toContain( + "spec:probe.next", + ); + } finally { + clearTimeout(timer); + abort.abort(); + rmSync(workspace, { recursive: true, force: true }); + } + }, + ); +}); + +describe("portable projection publication", () => { + it.each([ + ["view", "design-review"], + ["mermaid", "mermaid"], + ["gherkin", "gherkin"], + ])("%s refuses case-colliding pages and removes stale output", (command, directory) => { + const root = mkdtempSync(join(tmpdir(), "sdp-page-collision-")); + const view = join(root, "generated", directory); + mkdirSync(view, { recursive: true }); + writeFileSync(join(view, "stale.md"), "stale\n"); + writeCarrier(root, "first.sdp.md", "spec:probe.Twin"); + writeCarrier(root, "second.sdp.md", "spec:probe.twin"); + try { + const capture = createCaptureOutput(); + expect(runSdpCli([command, root], capture.output)).toBe(1); + expect(capture.readStderr()).toContain("case-insensitive publication path collision"); + expect(capture.readStderr()).toContain("spec:probe.Twin"); + expect(capture.readStderr()).toContain("spec:probe.twin"); + expect(existsSync(view)).toBe(false); + expect(existsSync(`${view}.tmp`)).toBe(false); + expect(existsSync(join(root, "generated", "graph.json"))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/exclude-diagnostics.test.ts b/test/exclude-diagnostics.test.ts index 07cd9c0ab..ae3862a17 100644 --- a/test/exclude-diagnostics.test.ts +++ b/test/exclude-diagnostics.test.ts @@ -1,9 +1,11 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { extract } from "../src/extract/index.js"; + import { discoverFiles, normalizeExcludes } from "../src/extract/discover.js"; const temporaryRoots: string[] = []; @@ -21,6 +23,54 @@ describe("exclusion diagnostics", () => { } }); + it("names each skipped symbolic link while resolving an explicitly linked root", () => { + const workspace = temporaryRoot(); + const root = join(workspace, "corpus"); + const outside = join(workspace, "outside"); + const alias = join(workspace, "alias"); + mkdirSync(root); + mkdirSync(outside); + symlinkSync(root, alias, "dir"); + writeFileSync( + join(root, "included.sdp.md"), + "---\nid: spec:probe.included\nkind: rule\naltitude: story\nreadiness: idea\nrelations: {}\n---\n# Included\n\n## Intent\n- outcome: Keep a real carrier.\n", + ); + writeFileSync(join(outside, "hidden.sdp.md"), "invalid carrier"); + for (const name of ["linked.sdp.md", "linked.ts", "other.txt"]) { + symlinkSync(join(outside, "hidden.sdp.md"), join(root, name)); + } + symlinkSync(outside, join(root, "directory"), "dir"); + symlinkSync(root, join(root, "loop"), "dir"); + symlinkSync(join(outside, "missing"), join(root, "dangling.sdp.md")); + for (const name of ["node_modules", "dist", "generated", "coverage", ".hidden", "excluded"]) { + symlinkSync(outside, join(root, name), "dir"); + } + mkdirSync(join(root, "omitted")); + symlinkSync(outside, join(root, "omitted", "nested"), "dir"); + const exclude = ["excluded", "omitted"]; + const discovered = discoverFiles(alias, exclude); + expect(discovered.specFiles).toEqual([ + { + absolutePath: join(realpathSync.native(root), "included.sdp.md"), + relativePath: "included.sdp.md", + }, + ]); + expect(discovered.anchorCandidateFiles).toEqual([]); + const result = extract({ root: alias, exclude }); + expect(result.counts).toEqual({ specs: 1, packs: 0, anchors: 0 }); + expect(result.report.findings).toEqual( + ["dangling.sdp.md", "directory", "linked.sdp.md", "linked.ts", "loop", "other.txt"].map( + (file) => ({ + validatorId: "extract/symbolic-link-skipped", + family: "conformance", + severity: "warning", + file, + message: `symbolic link "${file}" is skipped; discovery never follows links below the root`, + }), + ), + ); + }); + 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"; diff --git a/test/helpers/validator-audit.ts b/test/helpers/validator-audit.ts index 1c7d46b8f..f7cda4f69 100644 --- a/test/helpers/validator-audit.ts +++ b/test/helpers/validator-audit.ts @@ -1,4 +1,12 @@ -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, relative } from "node:path"; import ts from "typescript"; @@ -7,7 +15,9 @@ import { extract } from "../../src/extract/index.js"; import type { Finding } from "../../src/validate/contracts.js"; import { validateGraph } from "../../src/validate/validators.js"; -export type Probe = { graph: GraphSchema } | { carriers: Readonly> }; +export type Probe = + | { graph: GraphSchema } + | { carriers: Readonly>; links?: Readonly> }; export interface ProbeEntry { id: string; refusing: Probe; @@ -36,6 +46,11 @@ export function runValidatorProbe(probe: Probe): readonly Finding[] { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, text); } + for (const [file, target] of Object.entries(probe.links ?? {})) { + const path = join(root, file); + mkdirSync(dirname(path), { recursive: true }); + symlinkSync(join(root, target), path); + } return extract({ root }).report.findings; } finally { rmSync(root, { recursive: true, force: true }); diff --git a/test/helpers/validator-probes.ts b/test/helpers/validator-probes.ts index 5d5f5b28a..054eb8d04 100644 --- a/test/helpers/validator-probes.ts +++ b/test/helpers/validator-probes.ts @@ -103,6 +103,11 @@ const pack: GraphNode = { const ready = primitive(subject.id, { readiness: "ready" }); const graphPairs: ProbeEntry[] = [ + { + id: extractFindingIds.symbolicLinkSkipped, + refusing: { carriers: {}, links: { "linked.sdp.md": "missing.sdp.md" } }, + accepting: { carriers: {}, links: { "generated/linked.sdp.md": "missing.sdp.md" } }, + }, graphPair( graphValidatorIds.referentialIntegrity, graph([subject, target], [relation("dependsOn", "spec:probe.absent")]), diff --git a/test/iteration-recipes.test.ts b/test/iteration-recipes.test.ts index 504e86f59..dd300b151 100644 --- a/test/iteration-recipes.test.ts +++ b/test/iteration-recipes.test.ts @@ -52,6 +52,7 @@ async function runRecipe( ordinal: number, params: Record, derived: ExtractionResult, + expectedStderr = "", ): Promise { const capture = createCaptureOutput(); const exitCode = await runSdpCli( @@ -61,7 +62,10 @@ async function runRecipe( query: { extract: () => derived, isStdinTty: () => true }, }, ); - expect({ exitCode, stderr: capture.readStderr() }).toEqual({ exitCode: 0, stderr: "" }); + expect({ exitCode, stderr: capture.readStderr() }).toEqual({ + exitCode: 0, + stderr: expectedStderr, + }); return JSON.parse(capture.readStdout()) as T; } function specDocument(id: string, body: string, relations = "{}", kind = "behavior"): string { @@ -411,7 +415,12 @@ describe("iteration loop: design diff", () => { const head = extract({ root: repoRoot, exclude: excludes }); expect(base.report.findings.filter((finding) => finding.severity === "error")).toEqual([]); expect(head.report.findings.filter((finding) => finding.severity === "error")).toEqual([]); - const result = await runRecipe(35, { base: base.graph, detail: true }, head); + const result = await runRecipe( + 35, + { base: base.graph, detail: true }, + head, + 'CLAUDE.md — [warning] extract/symbolic-link-skipped — symbolic link "CLAUDE.md" is skipped; discovery never follows links below the root\n', + ); expect(result.base.specs).toBe( base.graph.nodes.filter((node) => node.nodeType === "Primitive").length, ); diff --git a/test/recipes.test.ts b/test/recipes.test.ts index 6bf329b03..01a3b1cf5 100644 --- a/test/recipes.test.ts +++ b/test/recipes.test.ts @@ -47,6 +47,9 @@ import { testAnchorId, } from "@libar-dev/software-delivery-protocol"; +const rootLinkWarning = + 'CLAUDE.md — [warning] extract/symbolic-link-skipped — symbolic link "CLAUDE.md" is skipped; discovery never follows links below the root\n'; + const repoRoot = fileURLToPath(new URL("..", import.meta.url)); const recipesPath = "docs/agent-surface/recipes.md"; @@ -181,13 +184,17 @@ async function runRecipe( }, ); - // The expected stderr is the empty string, not a self-comparison: a recipe run over the green - // corpus has nothing to say on stderr, and the object shape keeps the actual output in the - // failure diff when it does. + // The self-hosted corpus names its CLAUDE.md link; every other diagnostic still fails here. expect( { recipe: recipe.title, exitCode, stderr: capture.readStderr() }, `recipe ${String(recipe.ordinal)} must run as written`, - ).toEqual({ recipe: recipe.title, exitCode: 0, stderr: "" }); + ).toEqual({ + recipe: recipe.title, + exitCode: 0, + stderr: extraction.report.findings.some((finding) => finding.file === "CLAUDE.md") + ? rootLinkWarning + : "", + }); return JSON.parse(capture.readStdout()) as unknown; } @@ -2213,7 +2220,7 @@ describe("register recipe semantics", () => { queryHooks, ), ).toBe(0); - expect(capture.readStderr()).toBe(""); + expect(capture.readStderr()).toBe(rootLinkWarning); expect(capture.readStdout()).toContain("totals:"); }, ); @@ -4685,7 +4692,10 @@ describe("recipe parameters and the shipped recipe files", () => { writeFileSync(path, JSON.stringify({ files, detail: true })); const run = await runShipped(shippedBody(recipeByOrdinal(4)), ["--params", `@${path}`]); - expect({ exitCode: run.exitCode, stderr: run.stderr }).toEqual({ exitCode: 0, stderr: "" }); + expect({ exitCode: run.exitCode, stderr: run.stderr }).toEqual({ + exitCode: 0, + stderr: rootLinkWarning, + }); const result = asRecord(JSON.parse(run.stdout)); const radius = reader.blastRadius(files); @@ -4723,7 +4733,10 @@ describe("recipe parameters and the shipped recipe files", () => { "--params", JSON.stringify({ addresses }), ]); - expect({ exitCode: run.exitCode, stderr: run.stderr }).toEqual({ exitCode: 0, stderr: "" }); + expect({ exitCode: run.exitCode, stderr: run.stderr }).toEqual({ + exitCode: 0, + stderr: rootLinkWarning, + }); const result = asRecord(JSON.parse(run.stdout)); expect(result.totals).toEqual({ @@ -4751,7 +4764,10 @@ describe("recipe parameters and the shipped recipe files", () => { for (const ordinal of [5, 27]) { const body = shippedBody(recipeByOrdinal(ordinal)); const run = await runShipped(body, ["--params", JSON.stringify({ pack: chosen })]); - expect({ exitCode: run.exitCode, stderr: run.stderr }).toEqual({ exitCode: 0, stderr: "" }); + expect({ exitCode: run.exitCode, stderr: run.stderr }).toEqual({ + exitCode: 0, + stderr: rootLinkWarning, + }); const result = asRecord(JSON.parse(run.stdout)); expect(result.id).toBe(chosen); diff --git a/test/self-hosting-graph.test.ts b/test/self-hosting-graph.test.ts index df40b81ce..e83f22b12 100644 --- a/test/self-hosting-graph.test.ts +++ b/test/self-hosting-graph.test.ts @@ -336,8 +336,17 @@ describe("the self-hosting corpus", () => { }); // Then: the frozen corpus enters one graph with exact descriptors and its direct bindings. - it("reifies the root corpus without an extraction finding", () => { - expect(result.report.findings).toEqual([]); + it("names the root corpus's skipped symbolic link", () => { + expect(result.report.findings).toEqual([ + { + validatorId: "extract/symbolic-link-skipped", + family: "conformance", + severity: "warning", + file: "CLAUDE.md", + message: + 'symbolic link "CLAUDE.md" is skipped; discovery never follows links below the root', + }, + ]); }); it("reports exactly the retained verifier gaps and no unbacked prose mention", () => { diff --git a/test/self-hosting-model.test.ts b/test/self-hosting-model.test.ts index bd0de5861..151d7322d 100644 --- a/test/self-hosting-model.test.ts +++ b/test/self-hosting-model.test.ts @@ -206,17 +206,18 @@ void binding; ); }, "a relative import resolving to the Protocol builder modules": (root) => { - mkdirSync(join(root, "model"), { recursive: true }); + // Keep builder dependencies outside discovery; the example measures binding diagnostics. + mkdirSync(join(root, ".builders", "model"), { recursive: true }); mkdirSync(join(root, "deep", "nested"), { recursive: true }); - symlinkSync(join(packageRoot, "src", "ids.ts"), join(root, "ids.ts")); + symlinkSync(join(packageRoot, "src", "ids.ts"), join(root, ".builders", "ids.ts")); symlinkSync( join(packageRoot, "src", "model", "code-anchor.ts"), - join(root, "model", "code-anchor.ts"), + join(root, ".builders", "model", "code-anchor.ts"), ); writeFileSync( join(root, "deep", "nested", "binding.ts"), - `import { codeAnchor } from "../../model/code-anchor.js"; -import { codeAnchorId, ref } from "../../ids.js"; + `import { codeAnchor } from "../../.builders/model/code-anchor.js"; +import { codeAnchorId, ref } from "../../.builders/ids.js"; const binding = codeAnchor({ id: codeAnchorId("impl:protocol.deep-binding"), diff --git a/test/self-hosting-oracle/extraction.ts b/test/self-hosting-oracle/extraction.ts index 8deba17df..b966eb420 100644 --- a/test/self-hosting-oracle/extraction.ts +++ b/test/self-hosting-oracle/extraction.ts @@ -126,7 +126,7 @@ export const extractionSpecs = [ }, behavior: { rules: [ - "Discovery reads Spec and Pack carriers by the suffixes `.sdp.md`, `.sdp.gherkin` and `.sdp.ts`. Other `.ts` and `.tsx` files are anchor candidates, except `.d.ts` files. Discovery never descends into `node_modules`, `dist`, `generated`, `coverage` or a dot-directory, and configured exclusions apply to both lists.", + "Discovery reads Spec and Pack carriers by the suffixes `.sdp.md`, `.sdp.gherkin` and `.sdp.ts`. Other `.ts` and `.tsx` files are anchor candidates, except `.d.ts` files. Discovery never descends into `node_modules`, `dist`, `generated`, `coverage` or a dot-directory, and configured exclusions apply to both lists. Discovery never follows a symbolic link below the root; each link found outside an excluded or skipped path gets one warning naming it; a root named explicitly is resolved to its real directory first.", "An exclusion is an exact root-relative POSIX path prefix applied to Spec-carrier and anchor-candidate discovery; a repeated exclusion counts once.", "A prefix excludes itself and slash-delimited descendants only; it never excludes a merely similar sibling path.", "Empty, absolute, Windows-drive, backslash and trailing-slash paths, and paths with an empty, `.` or `..` segment, are refused rather than normalized into a different meaning.", From 3cecfc2590f83cdf1132d5f0b60f8187acd5c08f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Tue, 3 Nov 2026 19:38:04 +0100 Subject: [PATCH 09/16] fix: polish the CLI, extractor and package surface from the review L2-3: sdp q reports an unterminated template at its correct body position. L2-5: studio help states the build-owned-path refusal the CLI enforces. L3-03: Markdown ASCII trimming is linear, so long whitespace runs no longer stall extraction. L4-03: the vitest wrapper forwards option values and listings, keeping the dedicated CLI pass for executing commands. L9-F2: the root export ships CommonJS declarations for require consumers, and check:git-install walks nested conditions. C1: the temporal sweep no longer exempts the decision registry. From the whole-repository review (PR #38). Each finding was verified by a second model family, and this lane's diff was reviewed by the other. Co-Authored-By: Claude Opus 5.5 --- check-git-install.mjs | 7 +- check-temporal.mjs | 4 +- package.json | 13 ++- src/cli/help.ts | 5 +- src/extract/markdown-body-content.ts | 13 +-- src/extract/markdown-body.ts | 11 +-- src/extract/markdown-pack.ts | 12 ++- src/extract/markdown-support.ts | 18 +++++ src/reader/evaluate-body.ts | 11 +-- test/bootstrap.test.ts | 9 ++- test/check-temporal.test.ts | 12 ++- test/cli-q.test.ts | 11 +++ test/markdown-ascii-trim.test.ts | 54 +++++++++++++ test/package-smoke.test.ts | 25 ++++++ test/self-hosting-duplicate-ids.test.ts | 103 +++++++++++++++++++++++- test/studio-s3.test.ts | 23 ++++++ tsup.config.ts | 4 + vitest-test.mjs | 22 ++++- 18 files changed, 322 insertions(+), 35 deletions(-) create mode 100644 test/markdown-ascii-trim.test.ts diff --git a/check-git-install.mjs b/check-git-install.mjs index 1f8fcc592..9b09c57a8 100644 --- a/check-git-install.mjs +++ b/check-git-install.mjs @@ -91,8 +91,11 @@ try { const version = run(cli, ["--version"], consumer).trim(); assert.ok(version.includes(commit), `${version} does not name ${commit}`); const manifest = JSON.parse(readFileSync(join(installed, "package.json"), "utf8")); - for (const surface of Object.values(manifest.exports)) { - for (const path of Object.values(surface)) assert.ok(existsSync(join(installed, path)), path); + // Conditions nest (the root export pairs each of import and require with its own types). + const exportedPaths = (target) => + typeof target === "string" ? [target] : Object.values(target).flatMap(exportedPaths); + for (const path of exportedPaths(manifest.exports)) { + assert.ok(existsSync(join(installed, path)), path); } const recipe = readFileSync(join(installed, "dist/recipes/25-address-resolution.js"), "utf8"); writeFileSync( diff --git a/check-temporal.mjs b/check-temporal.mjs index 5d3bc4e59..d6a7f5b89 100644 --- a/check-temporal.mjs +++ b/check-temporal.mjs @@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url"; // (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, +// is written for one. Only temporal-by-genre artifacts are exempt, never the decision registry: // the per-session plan done-records, the archived reviews, the dated exploration records (verbatim // exhibits, diary-dated like DECISIONS), the restored lineage documents (verbatim historical // exhibits), the tracked orchestration drafts/plans/evidence under `.omo/` — plus the @@ -17,7 +17,7 @@ import { fileURLToPath } from "node:url"; 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"); -const excludedFiles = ["docs/concept/DECISIONS.md", "package-lock.json"]; +const excludedFiles = ["package-lock.json"]; const excludedDirectories = [ "plans/", "reviews/", diff --git a/package.json b/package.json index 6de740c47..a4da2d5ff 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,14 @@ }, "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } }, "./anchors": { "types": "./dist/anchors.d.ts", @@ -58,7 +63,7 @@ "node": ">=20" }, "scripts": { - "build": "tsup", + "build": "tsup && tsup src/index.ts --no-config --format cjs --dts-only", "prepare": "npm run build 1>&2", "typecheck": "tsc --noEmit -p tsconfig.json", "typecheck:examples": "tsc --noEmit -p tsconfig.examples.json", diff --git a/src/cli/help.ts b/src/cli/help.ts index 942c9f602..34d05d70a 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -72,10 +72,11 @@ const commands = { Findings remain visible; exit code follows validate. --check-clean independently re-renders and refuses missing or drifted output. --base PATH reads the graph JSON from an earlier sdp build and adds the design diff; --base-label TEXT names that base. - Keep the base outside /generated/; paths resolving inside it are refused. Pages open through file://. Repeat --runs PATH to read CTRF JSON reports as external run evidence from test files. - Unreadable or invalid JSON refuses before extraction; report diagnostics show on Runs.`, + Unreadable or invalid JSON refuses before extraction; report diagnostics show on Runs. + An input the build could write or remove, such as /generated/graph.json or a + file beneath a projection root, is refused, as is a link resolving to one.`, }, stamp: { name: "stamp", diff --git a/src/extract/markdown-body-content.ts b/src/extract/markdown-body-content.ts index 3dd522fe7..4cff1b80a 100644 --- a/src/extract/markdown-body-content.ts +++ b/src/extract/markdown-body-content.ts @@ -2,7 +2,12 @@ import { extractFindingIds } from "./reify.js"; import { hasRawHtmlOutsideCodeSpans } from "./markdown-inline-code.js"; import { parseSlots } from "../notation/slots.js"; import type { Finding } from "../validate/contracts.js"; -import { addMarkdownFinding, markdownFinding } from "./markdown-support.js"; +import { + addMarkdownFinding, + asciiTrim, + asciiTrimEnd, + markdownFinding, +} from "./markdown-support.js"; /** * @sdpAnchor impl:protocol.markdown-content @@ -39,8 +44,6 @@ export interface MarkdownFence { }; } -const ASCII_TRIM = /^[\t ]+|[\t ]+$/gu; - function bodyFinding(file: string, line: number, message: string): Finding { return markdownFinding(file, line, message, extractFindingIds.invalidMarkdownStructure); } @@ -49,7 +52,7 @@ export function normalizeProse(lines: readonly MarkdownLine[]): string { const paragraphs: string[] = []; let paragraph: string[] = []; for (const line of lines) { - const trimmed = line.text.replace(ASCII_TRIM, ""); + const trimmed = asciiTrim(line.text); if (trimmed.length === 0) { if (paragraph.length > 0) paragraphs.push(paragraph.join(" ")); paragraph = []; @@ -246,7 +249,7 @@ export function parseSectionContent( findings, bodyFinding(file, line.line, "an H3 owner is authored more than once"), ); - else h3 = { text: line.text.replace(/[\t ]+$/gu, ""), line: line.line }; + else h3 = { text: asciiTrimEnd(line.text), line: line.line }; continue; } if (line.text.startsWith("#")) { diff --git a/src/extract/markdown-body.ts b/src/extract/markdown-body.ts index cd9222432..98239936f 100644 --- a/src/extract/markdown-body.ts +++ b/src/extract/markdown-body.ts @@ -3,7 +3,12 @@ import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; import { codeAnchor } from "../model/code-anchor.js"; import { hasRawHtmlOutsideCodeSpans } from "./markdown-inline-code.js"; import type { Finding } from "../validate/contracts.js"; -import { addMarkdownFinding, capMarkdownFindings, markdownFinding } from "./markdown-support.js"; +import { + addMarkdownFinding, + asciiTrim, + capMarkdownFindings, + markdownFinding, +} from "./markdown-support.js"; import { mapOwner } from "./markdown-body-owners.js"; import { isUnsupportedCommonMarkBlock, normalizeProse } from "./markdown-body-content.js"; import type { MarkdownLine } from "./markdown-body-content.js"; @@ -41,10 +46,6 @@ function structure(file: string, line: number, message: string): Finding { return markdownFinding(file, line, message, extractFindingIds.invalidMarkdownStructure); } -function asciiTrim(text: string): string { - return text.replace(/^[\t ]+|[\t ]+$/gu, ""); -} - function levenshtein(left: string, right: string): number { let previous = Array.from({ length: right.length + 1 }, (_, index) => index); for (let row = 1; row <= left.length; row += 1) { diff --git a/src/extract/markdown-pack.ts b/src/extract/markdown-pack.ts index 89563cf3b..801f22c83 100644 --- a/src/extract/markdown-pack.ts +++ b/src/extract/markdown-pack.ts @@ -8,7 +8,13 @@ import { isUnsupportedCommonMarkBlock, normalizeProse } from "./markdown-body-co import type { MarkdownLine } from "./markdown-body-content.js"; import { readMarkdownEnvelope } from "./markdown-envelope.js"; import { parseMarkdownFrontmatter } from "./markdown-frontmatter.js"; -import { addMarkdownFinding, capMarkdownFindings, markdownFinding } from "./markdown-support.js"; +import { + addMarkdownFinding, + asciiTrim, + asciiTrimEnd, + capMarkdownFindings, + markdownFinding, +} from "./markdown-support.js"; import type { MarkdownFrontmatter } from "./markdown-types.js"; function structure(file: string, line: number, message: string): Finding { @@ -37,7 +43,7 @@ function parseMarkdownPackBody( }; const rawTitle = first.text.slice(2); - const title = rawTitle.replace(/^[\t ]+|[\t ]+$/gu, ""); + const title = asciiTrim(rawTitle); const findings: Finding[] = []; if (/^[\t ]/u.test(rawTitle) || title.length === 0) addMarkdownFinding( @@ -50,7 +56,7 @@ function parseMarkdownPackBody( const line = lines[index]; if (line === undefined) continue; if (/^#{2,} /u.test(line.text)) { - const heading = line.text.replace(/^#+[\t ]+/u, "").replace(/[\t ]+$/gu, ""); + const heading = asciiTrimEnd(line.text.replace(/^#+[\t ]+/u, "")); addMarkdownFinding( findings, markdownFinding( diff --git a/src/extract/markdown-support.ts b/src/extract/markdown-support.ts index 9deb77442..22ac54447 100644 --- a/src/extract/markdown-support.ts +++ b/src/extract/markdown-support.ts @@ -17,6 +17,24 @@ export function markdownLine(source: string, offset: number, baseLine: number): return baseLine + source.slice(0, offset).split(/\r\n|\n/u).length - 1; } +function isAsciiBlank(character: string | undefined): boolean { + return character === " " || character === "\t"; +} + +// A scan rather than `/[\t ]+$/`: that regex retries every start inside an interior run of +// blanks, so one long padded line would make extraction quadratic in its length. +export function asciiTrimEnd(text: string): string { + let end = text.length; + while (end > 0 && isAsciiBlank(text[end - 1])) end -= 1; + return text.slice(0, end); +} + +export function asciiTrim(text: string): string { + let start = 0; + while (start < text.length && isAsciiBlank(text[start])) start += 1; + return asciiTrimEnd(text.slice(start)); +} + export function addMarkdownFinding(findings: Finding[], entry: Finding): void { if (findings.length < MAX_FINDINGS) findings.push(entry); } diff --git a/src/reader/evaluate-body.ts b/src/reader/evaluate-body.ts index 140008a3e..a9f981876 100644 --- a/src/reader/evaluate-body.ts +++ b/src/reader/evaluate-body.ts @@ -58,16 +58,17 @@ export function compileBody(source: string): CompiledBody { }); } catch (syntaxError) { const stack = syntaxError instanceof Error ? (syntaxError.stack ?? "") : ""; - const position = /^sdp-q-body:(\d+)\n[^\n]*\n([^\n^]*)\^/u.exec(stack); + // The line and the caret are read apart: an unterminated template literal leaves the + // engine's stack without a caret, yet its line still marks where the body stopped. + const lineMatch = /^sdp-q-body:(\d+)/u.exec(stack); + const caret = /^sdp-q-body:\d+\n[^\n]*\n([^\n^]*)\^/u.exec(stack); const lines = source.split("\n"); - const wrapperLine = Number(position?.[1] ?? 2); + const wrapperLine = Number(lineMatch?.[1] ?? 2); const line = Math.max(1, Math.min(lines.length, wrapperLine - 1)); // An error on the wrapper's closing line means the body stopped short; the engine would // name the wrapper's own brace, a token the caller never wrote. const pastEnd = wrapperLine - 1 > lines.length; - const column = pastEnd - ? (lines[line - 1]?.length ?? 0) + 1 - : (position?.[2]?.length ?? 0) + 1; + const column = pastEnd ? (lines[line - 1]?.length ?? 0) + 1 : (caret?.[1]?.length ?? 0) + 1; const message = pastEnd ? "the body ends before it is complete" : oneLine(errorMessage(syntaxError)); diff --git a/test/bootstrap.test.ts b/test/bootstrap.test.ts index 071a1ef56..b780739b9 100644 --- a/test/bootstrap.test.ts +++ b/test/bootstrap.test.ts @@ -49,7 +49,7 @@ describe("bootstrap package surface", () => { type: string; packageManager?: string; bin: { sdp: string }; - exports: Record; + exports: Record; scripts: Record; }; const rootExport = packageJson.exports["."]; @@ -61,8 +61,11 @@ describe("bootstrap package surface", () => { expect(packageJson.type).toBe("module"); expect(packageJson.packageManager).toBeUndefined(); expect(packageJson.bin.sdp).toBe("./dist/cli/sdp.js"); - expect(rootExport.types).toBe("./dist/index.d.ts"); - expect(rootExport.import).toBe("./dist/index.js"); + // Each condition carries its own declarations: the ESM .d.ts cannot type a `require`. + expect(rootExport).toEqual({ + import: { types: "./dist/index.d.ts", default: "./dist/index.js" }, + require: { types: "./dist/index.d.cts", default: "./dist/index.cjs" }, + }); expect(packageJson.scripts["generate:self-hosting"]).toBe( "node ./projection-suite.mjs . --exclude explorations --exclude examples --exclude test/fixtures/import/parity", ); diff --git a/test/check-temporal.test.ts b/test/check-temporal.test.ts index fd9fa419d..16e7e14d6 100644 --- a/test/check-temporal.test.ts +++ b/test/check-temporal.test.ts @@ -107,6 +107,17 @@ describe("check:temporal", () => { }); }); + it("sweeps the decision registry, which holds current truth rather than a dated diary", () => { + withRoot((root) => { + writeExcludedFile(root, "docs/concept/DECISIONS.md"); + + const result = runGuard(root); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("docs/concept/DECISIONS.md:1"); + }); + }); + it("accepts an in-repository directory symlink when its canonical files are tracked", () => { withRoot((root) => { mkdirSync(join(root, ".agents", "skills", "example"), { recursive: true }); @@ -137,7 +148,6 @@ describe("check:temporal", () => { it("keeps each explicit temporal genre exclusion", () => { withRoot((root) => { - writeExcludedFile(root, "docs/concept/DECISIONS.md"); writeExcludedFile(root, "plans/record.md"); writeExcludedFile(root, "reviews/record.md"); writeExcludedFile(root, "explorations/record.md"); diff --git a/test/cli-q.test.ts b/test/cli-q.test.ts index d4ba258e2..220f8c1e8 100644 --- a/test/cli-q.test.ts +++ b/test/cli-q.test.ts @@ -767,6 +767,17 @@ describe("query body positions and merged parameters", () => { ); }); + it.each([ + ["return `abc", 1, 12], + ["const a = 1;\nreturn `x${a`", 2, 14], + ])("names a body that stops inside a template literal %s", async (body, line, column) => { + const capture = createCaptureOutput(); + expect(await runQ([body, "--root", scratch], capture)).toBe(1); + expect(capture.readStderr()).toBe( + `sdp q: the body does not parse at line ${String(line)}, column ${String(column)}: the body ends before it is complete\n`, + ); + }); + it("keeps runtime failures separate, including a thrown SyntaxError", async () => { const capture = createCaptureOutput(); expect( diff --git a/test/markdown-ascii-trim.test.ts b/test/markdown-ascii-trim.test.ts new file mode 100644 index 000000000..7d7c6f692 --- /dev/null +++ b/test/markdown-ascii-trim.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { reifyMarkdownCarrier } from "../src/index.js"; + +// A regex trim retried every start inside an interior run of blanks: one such run of this length +// took seconds, and each carrier below holds two inside its 256 KiB limit. The budget leaves room +// for a slow machine while a quadratic trim still exceeds it several times. +const run = " ".repeat(60_000); +const padded = `word${run}word`; +const budgetMs = 1_000; + +function timed(work: () => T): { readonly value: T; readonly ms: number } { + const start = performance.now(); + const value = work(); + return { value, ms: performance.now() - start }; +} + +describe("Markdown ASCII trimming", () => { + it("keeps a long interior blank run in Spec prose and titles linear and intact", () => { + const source = `--- +id: spec:markdown.padded +kind: behavior +altitude: story +readiness: idea +relations: {} +--- +# Padded${run}title\t \t + +${padded}\t +`; + const { value, ms } = timed(() => reifyMarkdownCarrier(source, "padded.sdp.md")); + expect(value.findings).toEqual([]); + // Compared as booleans: a failing match would otherwise print the whole run. + expect(value.specs[0]?.data.title === `Padded${run}title`).toBe(true); + expect(value.specs[0]?.data.narrative === padded).toBe(true); + expect(ms).toBeLessThan(budgetMs); + }); + + it("keeps a long interior blank run in Pack prose and titles linear and intact", () => { + const source = `--- +id: pack:padded +specs: [] +--- +# Padded${run}pack \t + +${padded} \t +`; + const { value, ms } = timed(() => reifyMarkdownCarrier(source, "padded.pack.sdp.md")); + expect(value.findings).toEqual([]); + expect(value.packs[0]?.data.title === `Padded${run}pack`).toBe(true); + expect(value.packs[0]?.data.framing === padded).toBe(true); + expect(ms).toBeLessThan(budgetMs); + }); +}); diff --git a/test/package-smoke.test.ts b/test/package-smoke.test.ts index d47e36981..b978ff798 100644 --- a/test/package-smoke.test.ts +++ b/test/package-smoke.test.ts @@ -518,6 +518,31 @@ void [${expectedRootExports.join(", ")}]; ], consumer, ); + // Node16 resolution forbids a CommonJS file from typing an ESM declaration, so the + // `require` condition must name declarations of its own. + await writeFile( + join(consumer, "type-surface.cts"), + `import { ${expectedRootExports.join(", ")} } from "${packageName}"; +import type { ${typeOnlyRootExports.join(", ")} } from "${packageName}"; +void [${expectedRootExports.join(", ")}]; +`, + ); + run( + join(repositoryRoot, "node_modules", ".bin", "tsc"), + [ + "--module", + "Node16", + "--moduleResolution", + "Node16", + "--target", + "ES2022", + "--strict", + "--skipLibCheck", + "--noEmit", + "type-surface.cts", + ], + consumer, + ); const sdpHelp = run(join(consumer, "node_modules", ".bin", "sdp"), ["--help"], consumer); for (const path of [...adopterAssets, "schemas/sdp-proposal-1.schema.json", "specs/"]) { diff --git a/test/self-hosting-duplicate-ids.test.ts b/test/self-hosting-duplicate-ids.test.ts index 288e4982e..f1ce4da92 100644 --- a/test/self-hosting-duplicate-ids.test.ts +++ b/test/self-hosting-duplicate-ids.test.ts @@ -230,6 +230,18 @@ describe("generated contract preflight", () => { ); }); + it.each([[["--reporter", "verbose"]], [["--maxWorkers", "1"]], [["-t", "duplicate"]]])( + "reads an option value %j as no file filter, so the whole suite's contracts are required", + (options) => { + const result = runPreflight(preflightRoot([]), options); + + expect(result.status).toBe(1); + expect(result.stderr).toBe( + "Generated contracts required by the selected test suite are missing.\nRun `npm run build && npm run generate:self-hosting && npm run generate:example` first.\n", + ); + }, + ); + it("does not require contracts for an unrelated Vitest filter", () => { expect(runPreflight(preflightRoot([]), ["test/bootstrap.test.ts"]).status).toBe(0); }); @@ -241,8 +253,11 @@ describe("generated contract preflight", () => { }); }); -function shimmedPoolRoot(shimBody: string): { root: string; environment: NodeJS.ProcessEnv } { - const root = preflightRoot([]); +function shimmedPoolRoot( + shimBody: string, + contractDirectories: readonly string[] = [], +): { root: string; environment: NodeJS.ProcessEnv } { + const root = preflightRoot(contractDirectories); mkdirSync(join(root, "generated"), { recursive: true }); writeFileSync(join(root, "generated", "sentinel.txt"), "before\n"); @@ -280,6 +295,90 @@ describe("pooled root generated-state sentinel", () => { ); }); + it("keeps the dedicated CLI pass when an option carries a separate value", () => { + const { root, environment } = shimmedPoolRoot( + 'require("node:fs").appendFileSync("spawns.log", JSON.stringify(process.argv.slice(2)) + "\\n");', + [selfHostingContracts, checkoutContracts], + ); + const result = spawnSync( + process.execPath, + [join(repoRoot, "vitest-test.mjs"), "--run", "--reporter", "verbose"], + { cwd: root, encoding: "utf8", env: environment }, + ); + + expect(result.status).toBe(0); + const spawns = readFileSync(join(root, "spawns.log"), "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as string[]); + expect(spawns).toEqual([ + ["--run", "--reporter", "verbose", "--exclude", "test/cli.test.ts"], + ["--run", "test/cli.test.ts", "--pool", "forks", "--maxWorkers", "1", "--no-isolate"], + ]); + }); + + it.each([[["list", "--filesOnly"]], [["--reporter", "verbose", "list", "test/cli.test.ts"]]])( + "forwards the listing command %j once, without running the CLI suite", + (options) => { + const { root, environment } = shimmedPoolRoot( + 'require("node:fs").appendFileSync("spawns.log", JSON.stringify(process.argv.slice(2)) + "\\n");', + [selfHostingContracts, checkoutContracts], + ); + const result = spawnSync(process.execPath, [join(repoRoot, "vitest-test.mjs"), ...options], { + cwd: root, + encoding: "utf8", + env: environment, + }); + + expect(result.status).toBe(0); + const spawns = readFileSync(join(root, "spawns.log"), "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as string[]); + expect(spawns).toEqual([["--run", ...options]]); + }, + ); + + it.each([["watch"], ["dev"]])( + "keeps the dedicated CLI pass for the executing command %s", + (command) => { + // The shim stands in for the CLI suite: it regenerates generated/ whenever it executes + // test/cli.test.ts, which only the dedicated pass may do. + const { root, environment } = shimmedPoolRoot( + [ + 'const fs = require("node:fs");', + "const args = process.argv.slice(2);", + 'fs.appendFileSync("spawns.log", JSON.stringify(args) + "\\n");', + 'if (args.includes("test/cli.test.ts") && !args.includes("--exclude")) fs.writeFileSync("generated/sentinel.txt", "after\\n");', + ].join("\n"), + [selfHostingContracts, checkoutContracts], + ); + const result = spawnSync( + process.execPath, + [join(repoRoot, "vitest-test.mjs"), command, "test/cli.test.ts"], + { cwd: root, encoding: "utf8", env: environment }, + ); + + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + const spawns = readFileSync(join(root, "spawns.log"), "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as string[]); + expect(spawns).toEqual([ + [ + "--run", + command, + "test/cli.test.ts", + "--exclude", + "test/cli.test.ts", + "--passWithNoTests", + ], + ["--run", "test/cli.test.ts", "--pool", "forks", "--maxWorkers", "1", "--no-isolate"], + ]); + }, + ); + it("passes a pooled run that leaves repository-root generated state untouched", () => { const { root, environment } = shimmedPoolRoot(""); const result = runPooledWrapper(root, environment); diff --git a/test/studio-s3.test.ts b/test/studio-s3.test.ts index 1daa60745..257247bf2 100644 --- a/test/studio-s3.test.ts +++ b/test/studio-s3.test.ts @@ -424,6 +424,29 @@ describe("Studio register pages", () => { }); describe("Studio base comparison", () => { + it("states in its help the refusal the command applies: build-owned paths, not all of generated/", async () => { + const help = createCaptureOutput(); + expect(await runSdpCli(["studio", "--help"], help.output)).toBe(0); + const text = help.readStdout().replace(/\s+/gu, " "); + expect(text).toContain( + "An input the build could write or remove, such as /generated/graph.json or a file beneath a projection root, is refused", + ); + expect(text).not.toContain("paths resolving inside it are refused"); + + const root = corpus({ "a.sdp.md": carrier("spec:probe.alpha", "", "{}", "idea") }); + const build = createCaptureOutput(); + expect(await runSdpCli(["build", root], build.output)).toBe(0); + const base = join(root, "generated/kept-base.json"); + writeFileSync(base, readFileSync(join(root, "generated/graph.json"))); + const assets = { script: join(root, "fixture.js"), stylesheet: join(root, "fixture.css") }; + writeFileSync(assets.script, ""); + writeFileSync(assets.stylesheet, ""); + const accepted = createCaptureOutput(); + expect( + await runSdpCli(["studio", root, "--base", base], accepted.output, { studioAssets: assets }), + ).toBe(0); + expect(accepted.readStderr()).not.toContain("--base"); + }); it.each(["graph", "nested", "file alias", "root alias", "input alias"])( "refuses a base in generated output before writing through %s", async (variant) => { diff --git a/tsup.config.ts b/tsup.config.ts index 930228c94..e76d779f8 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -39,6 +39,10 @@ export default defineConfig([ return Promise.resolve(); }, }, + // A CommonJS TypeScript consumer cannot type `require` from the ESM dist/index.d.ts, so the + // build script's second tsup pass emits dist/index.d.cts. It runs after this whole array: the + // first config's declaration build deletes every declaration in dist/ as it starts, so one + // emitted here would race that delete. { entry: ["src/index.ts"], format: ["cjs"], diff --git a/vitest-test.mjs b/vitest-test.mjs index 395d004cc..b170a45bc 100644 --- a/vitest-test.mjs +++ b/vitest-test.mjs @@ -4,6 +4,8 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, isAbsolute, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; +import { parseCLI } from "vitest/node"; + import { contractDependentSuites } from "./contract-dependent-suites.mjs"; const argv = process.argv.slice(2); @@ -16,7 +18,18 @@ const repositoryRoot = dirname(fileURLToPath(import.meta.url)); // too, so a new bound suite is named once and both surfaces follow. const contractDependencies = contractDependentSuites; -const pathFilters = argv.filter((argument) => !argument.startsWith("-")); +// Vitest's own parser separates file filters from option values, so `--reporter verbose` keeps +// the dedicated CLI pass and the contract preflight. Parsing behind a fixed `run` keeps a selected +// command such as `list` among the positionals instead of consuming it. Help is left out: parsing +// it prints usage. +const vitestCommands = new Set(["run", "watch", "dev", "related", "bench", "init", "list"]); +const positionals = parseCLI([ + "vitest", + "run", + ...argv.filter((argument) => argument !== "--help" && argument !== "-h"), +]).filter; +const command = vitestCommands.has(positionals[0]) ? positionals[0] : undefined; +const pathFilters = command === undefined ? positionals : positionals.slice(1); const hasPathFilter = pathFilters.length > 0; const cliTestPath = "test/cli.test.ts"; @@ -127,6 +140,13 @@ if (argv.includes("--help")) { process.exit(runVitest(vitestArgs)); } +// A command that executes no test, such as `list`, is forwarded once as selected. Executing +// commands (`run`, and `watch` or `dev`, which the added `--run` makes single-pass) keep the split, +// so test/cli.test.ts still runs only in its dedicated process. +if (command === "list" || command === "init") { + process.exit(runPooledVitest(vitestArgs)); +} + if (hasPathFilter && !selectsCliTest) { process.exit(runPooledVitest(vitestArgs)); } From 4ab16165d1d6418af77688f007b99fed85d7d8df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Tue, 3 Nov 2026 19:49:30 +0100 Subject: [PATCH 10/16] fix: integrate the review lanes Nothing failed the gate after the merges; the full npm run check passed at 58a16908. The merge did leave the handbook stating a falsehood that neither lane could see alone. Lane D rewrote the AGENTS.md paragraph to say check:self-hosting emits only honesty/gaps warnings and that any other new warning is a regression. Lane W's discovery ruling (Q1) makes the same step's build line count one pinned extraction warning, extract/symbolic-link-skipped for the CLAUDE.md link. Lane D's test read only validateGraph's findings, so it passed while the paragraph misled. The paragraph now names that pinned extraction warning, and the handbook test also requires every pinned extraction finding to be named there. Without the sentence the test fails: expected the paragraph to contain "`extract/symbolic-link-skipped` names the `CLAUDE.md` link". The one merge conflict, lane H2 against lane C's oracle totals, was resolved in the H2 merge by adding both sides' deltas and confirming them with sdp validate and the oracle tests: 459 Specs, 609 anchors, 812 binding edges, 1077 nodes, 2989 edges. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 ++- test/self-hosting-graph.test.ts | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 62861b877..53fd9de58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -297,4 +297,5 @@ The `check:self-hosting` step emits one kind of intentional warning: `honesty/ga owner-ratified ready Spec, decisions aside, without a resolving verifier binding. The self-hosting oracle pins that set and pins that no prose mention lacks a declared relation (recipe 22's `unbacked` stays empty), so a `conformance/prose-mentions` warning, like any other new warning, is -a regression. +a regression. Its build line also counts one extraction warning the oracle pins: +`extract/symbolic-link-skipped` names the `CLAUDE.md` link, which discovery never follows. diff --git a/test/self-hosting-graph.test.ts b/test/self-hosting-graph.test.ts index 9b3b65d2e..19d79b6f6 100644 --- a/test/self-hosting-graph.test.ts +++ b/test/self-hosting-graph.test.ts @@ -372,6 +372,10 @@ describe("the self-hosting corpus", () => { expect([...firstSentence.matchAll(/`([a-z]+\/[a-z-]+)`/gu)].map((match) => match[1])).toEqual([ ...new Set(expectedWarnings.map(({ validatorId }) => validatorId)), ]); + // The extraction warnings print on the same step's build line, so the paragraph names them too. + for (const { validatorId, file } of result.report.findings) { + expect(paragraph).toContain(`\`${validatorId}\` names the \`${file ?? ""}\` link`); + } }); // The registry's preamble: a decision that narrows another refines it, and the narrowed row From 339081b2c131f7a98109c2f883f687677e6ae3e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Tue, 3 Nov 2026 20:09:05 +0100 Subject: [PATCH 11/16] fix(extract): warn only for symbolic links that could hide a carrier or anchor Q1n: discovery still never follows a link below the root, but a link now warns only when it targets a directory or its name is a carrier or anchor-candidate name; the CLAUDE.md link no longer warns, and the handbook sentence and test pins for it are removed. Carries out the owner's rulings on the whole-repository review (PR #38); the diff was reviewed by the other model family. Narrows the owner's Q1 ruling to candidate links; awaiting the owner's review. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 +- specs/extraction/excludes.sdp.md | 2 +- src/extract/discover.ts | 27 ++++++++- test/exclude-diagnostics.test.ts | 77 ++++++++++++++++++++++---- test/iteration-recipes.test.ts | 10 +--- test/recipes.test.ts | 16 ++---- test/self-hosting-graph.test.ts | 17 +----- test/self-hosting-oracle/extraction.ts | 2 +- 8 files changed, 103 insertions(+), 51 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 53fd9de58..62861b877 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -297,5 +297,4 @@ The `check:self-hosting` step emits one kind of intentional warning: `honesty/ga owner-ratified ready Spec, decisions aside, without a resolving verifier binding. The self-hosting oracle pins that set and pins that no prose mention lacks a declared relation (recipe 22's `unbacked` stays empty), so a `conformance/prose-mentions` warning, like any other new warning, is -a regression. Its build line also counts one extraction warning the oracle pins: -`extract/symbolic-link-skipped` names the `CLAUDE.md` link, which discovery never follows. +a regression. diff --git a/specs/extraction/excludes.sdp.md b/specs/extraction/excludes.sdp.md index 7309fe60e..97e1dfd28 100644 --- a/specs/extraction/excludes.sdp.md +++ b/specs/extraction/excludes.sdp.md @@ -13,7 +13,7 @@ relations: - outcome: Keep consumer-selected omissions precise without changing the extractor's canonical discovery rules. ## Rule -- [#canonicalDiscovery] Discovery reads Spec and Pack carriers by the suffixes `.sdp.md`, `.sdp.gherkin` and `.sdp.ts`. Other `.ts` and `.tsx` files are anchor candidates, except `.d.ts` files. Discovery never descends into `node_modules`, `dist`, `generated`, `coverage` or a dot-directory, and configured exclusions apply to both lists. Discovery never follows a symbolic link below the root; each link found outside an excluded or skipped path gets one warning naming it; a root named explicitly is resolved to its real directory first. +- [#canonicalDiscovery] Discovery reads Spec and Pack carriers by the suffixes `.sdp.md`, `.sdp.gherkin` and `.sdp.ts`. Other `.ts` and `.tsx` files are anchor candidates, except `.d.ts` files. Discovery never descends into `node_modules`, `dist`, `generated`, `coverage` or a dot-directory, and configured exclusions apply to both lists. Discovery never follows a symbolic link below the root; outside excluded or skipped paths, a link gets one warning naming it only if it targets a directory or its name matches a carrier or anchor-candidate filename, and other file links are silent; a root named explicitly is resolved to its real directory first. - An exclusion is an exact root-relative POSIX path prefix applied to Spec-carrier and anchor-candidate discovery; a repeated exclusion counts once. - [#segmentBoundary] A prefix excludes itself and slash-delimited descendants only; it never excludes a merely similar sibling path. - [#refusedPaths] Empty, absolute, Windows-drive, backslash and trailing-slash paths, and paths with an empty, `.` or `..` segment, are refused rather than normalized into a different meaning. diff --git a/src/extract/discover.ts b/src/extract/discover.ts index 956d7a523..575c8323b 100644 --- a/src/extract/discover.ts +++ b/src/extract/discover.ts @@ -1,4 +1,4 @@ -import { readdirSync, realpathSync } from "node:fs"; +import { readdirSync, realpathSync, statSync } from "node:fs"; import { join } from "node:path"; import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; @@ -103,6 +103,20 @@ function byRelativePath(left: DiscoveredSourceFile, right: DiscoveredSourceFile) return compareCodeUnits(left.relativePath, right.relativePath); } +function isSpecFileName(name: string): boolean { + return SPEC_FILE_SUFFIXES.some((suffix) => name.endsWith(suffix)); +} + +function isDirectoryLink(path: string): boolean { + try { + // Inspect only the target type; discovery never reads or traverses the target. + return statSync(path).isDirectory(); + } catch { + // An unresolved target has no directory type; candidate filenames still warn. + return false; + } +} + function isSourceFileName(name: string): boolean { return ( SOURCE_FILE_SUFFIXES.some((suffix) => name.endsWith(suffix)) && @@ -130,7 +144,14 @@ function walkDirectory( } if (entry.isSymbolicLink()) { - if (!isExcludedDiscoveryDirectory(entry.name)) state.skippedLinks.push(relativePath); + if ( + !isExcludedDiscoveryDirectory(entry.name) && + (isSpecFileName(entry.name) || + isSourceFileName(entry.name) || + isDirectoryLink(join(absoluteDirectory, entry.name))) + ) { + state.skippedLinks.push(relativePath); + } continue; } @@ -149,7 +170,7 @@ function walkDirectory( continue; } - if (SPEC_FILE_SUFFIXES.some((suffix) => entry.name.endsWith(suffix))) { + if (isSpecFileName(entry.name)) { state.specFiles.push({ absolutePath: join(absoluteDirectory, entry.name), relativePath }); continue; } diff --git a/test/exclude-diagnostics.test.ts b/test/exclude-diagnostics.test.ts index ae3862a17..f88f7e464 100644 --- a/test/exclude-diagnostics.test.ts +++ b/test/exclude-diagnostics.test.ts @@ -23,7 +23,7 @@ describe("exclusion diagnostics", () => { } }); - it("names each skipped symbolic link while resolving an explicitly linked root", () => { + it("names skipped candidate links while resolving an explicitly linked root", () => { const workspace = temporaryRoot(); const root = join(workspace, "corpus"); const outside = join(workspace, "outside"); @@ -59,18 +59,75 @@ describe("exclusion diagnostics", () => { const result = extract({ root: alias, exclude }); expect(result.counts).toEqual({ specs: 1, packs: 0, anchors: 0 }); expect(result.report.findings).toEqual( - ["dangling.sdp.md", "directory", "linked.sdp.md", "linked.ts", "loop", "other.txt"].map( - (file) => ({ - validatorId: "extract/symbolic-link-skipped", - family: "conformance", - severity: "warning", - file, - message: `symbolic link "${file}" is skipped; discovery never follows links below the root`, - }), - ), + ["dangling.sdp.md", "directory", "linked.sdp.md", "linked.ts", "loop"].map((file) => ({ + validatorId: "extract/symbolic-link-skipped", + family: "conformance", + severity: "warning", + file, + message: `symbolic link "${file}" is skipped; discovery never follows links below the root`, + })), ); }); + // spec:extraction.excludes#rule.canonicalDiscovery + it.each([ + ["CLAUDE.md", false], + ["types.d.ts", false], + ["other.txt", false], + ["other.feature", false], + ["x.sdp.md", true], + ["x.pack.sdp.md", true], + ["x.sdp.ts", true], + ["x.sdp.gherkin", true], + ["source.ts", true], + ["source.tsx", true], + ])("warns for the skipped file link %s only when its name is a candidate", (name, warns) => { + const root = temporaryRoot(); + writeFileSync(join(root, "AGENTS.md"), "This file is not a discovery candidate.\n"); + symlinkSync("AGENTS.md", join(root, name)); + + const result = extract({ root }); + + expect(result.counts).toEqual({ specs: 0, packs: 0, anchors: 0 }); + expect(result.report.findings).toEqual( + warns + ? [ + { + validatorId: "extract/symbolic-link-skipped", + family: "conformance", + severity: "warning", + file: name, + message: `symbolic link "${name}" is skipped; discovery never follows links below the root`, + }, + ] + : [], + ); + }); + + it("warns once for a directory link even when its name is not a candidate filename", () => { + const workspace = temporaryRoot(); + const root = join(workspace, "corpus"); + const outside = join(workspace, "outside"); + mkdirSync(root); + mkdirSync(outside); + writeFileSync(join(outside, "hidden.sdp.md"), "invalid carrier"); + symlinkSync(outside, join(root, "CLAUDE.md"), "dir"); + + const result = extract({ root }); + + expect(result.counts).toEqual({ specs: 0, packs: 0, anchors: 0 }); + expect(result.report.findings).toEqual([ + { + validatorId: "extract/symbolic-link-skipped", + family: "conformance", + severity: "warning", + file: "CLAUDE.md", + message: + 'symbolic link "CLAUDE.md" is skipped; discovery never follows links below the root', + }, + ]); + }); + 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"; diff --git a/test/iteration-recipes.test.ts b/test/iteration-recipes.test.ts index dd300b151..7efa553e3 100644 --- a/test/iteration-recipes.test.ts +++ b/test/iteration-recipes.test.ts @@ -52,7 +52,6 @@ async function runRecipe( ordinal: number, params: Record, derived: ExtractionResult, - expectedStderr = "", ): Promise { const capture = createCaptureOutput(); const exitCode = await runSdpCli( @@ -64,7 +63,7 @@ async function runRecipe( ); expect({ exitCode, stderr: capture.readStderr() }).toEqual({ exitCode: 0, - stderr: expectedStderr, + stderr: "", }); return JSON.parse(capture.readStdout()) as T; } @@ -415,12 +414,7 @@ describe("iteration loop: design diff", () => { const head = extract({ root: repoRoot, exclude: excludes }); expect(base.report.findings.filter((finding) => finding.severity === "error")).toEqual([]); expect(head.report.findings.filter((finding) => finding.severity === "error")).toEqual([]); - const result = await runRecipe( - 35, - { base: base.graph, detail: true }, - head, - 'CLAUDE.md — [warning] extract/symbolic-link-skipped — symbolic link "CLAUDE.md" is skipped; discovery never follows links below the root\n', - ); + const result = await runRecipe(35, { base: base.graph, detail: true }, head); expect(result.base.specs).toBe( base.graph.nodes.filter((node) => node.nodeType === "Primitive").length, ); diff --git a/test/recipes.test.ts b/test/recipes.test.ts index 6d69b8502..9bd4fba7d 100644 --- a/test/recipes.test.ts +++ b/test/recipes.test.ts @@ -47,9 +47,6 @@ import { testAnchorId, } from "@libar-dev/software-delivery-protocol"; -const rootLinkWarning = - 'CLAUDE.md — [warning] extract/symbolic-link-skipped — symbolic link "CLAUDE.md" is skipped; discovery never follows links below the root\n'; - const repoRoot = fileURLToPath(new URL("..", import.meta.url)); const recipesPath = "docs/agent-surface/recipes.md"; @@ -184,16 +181,13 @@ async function runRecipe( }, ); - // The self-hosted corpus names its CLAUDE.md link; every other diagnostic still fails here. expect( { recipe: recipe.title, exitCode, stderr: capture.readStderr() }, `recipe ${String(recipe.ordinal)} must run as written`, ).toEqual({ recipe: recipe.title, exitCode: 0, - stderr: extraction.report.findings.some((finding) => finding.file === "CLAUDE.md") - ? rootLinkWarning - : "", + stderr: "", }); return JSON.parse(capture.readStdout()) as unknown; @@ -2262,7 +2256,7 @@ describe("register recipe semantics", () => { queryHooks, ), ).toBe(0); - expect(capture.readStderr()).toBe(rootLinkWarning); + expect(capture.readStderr()).toBe(""); expect(capture.readStdout()).toContain("totals:"); }, ); @@ -4736,7 +4730,7 @@ describe("recipe parameters and the shipped recipe files", () => { const run = await runShipped(shippedBody(recipeByOrdinal(4)), ["--params", `@${path}`]); expect({ exitCode: run.exitCode, stderr: run.stderr }).toEqual({ exitCode: 0, - stderr: rootLinkWarning, + stderr: "", }); const result = asRecord(JSON.parse(run.stdout)); @@ -4777,7 +4771,7 @@ describe("recipe parameters and the shipped recipe files", () => { ]); expect({ exitCode: run.exitCode, stderr: run.stderr }).toEqual({ exitCode: 0, - stderr: rootLinkWarning, + stderr: "", }); const result = asRecord(JSON.parse(run.stdout)); @@ -4808,7 +4802,7 @@ describe("recipe parameters and the shipped recipe files", () => { const run = await runShipped(body, ["--params", JSON.stringify({ pack: chosen })]); expect({ exitCode: run.exitCode, stderr: run.stderr }).toEqual({ exitCode: 0, - stderr: rootLinkWarning, + stderr: "", }); const result = asRecord(JSON.parse(run.stdout)); diff --git a/test/self-hosting-graph.test.ts b/test/self-hosting-graph.test.ts index 19d79b6f6..e5db22d63 100644 --- a/test/self-hosting-graph.test.ts +++ b/test/self-hosting-graph.test.ts @@ -336,17 +336,8 @@ describe("the self-hosting corpus", () => { }); // Then: the frozen corpus enters one graph with exact descriptors and its direct bindings. - it("names the root corpus's skipped symbolic link", () => { - expect(result.report.findings).toEqual([ - { - validatorId: "extract/symbolic-link-skipped", - family: "conformance", - severity: "warning", - file: "CLAUDE.md", - message: - 'symbolic link "CLAUDE.md" is skipped; discovery never follows links below the root', - }, - ]); + it("extracts the root corpus without diagnostics", () => { + expect(result.report.findings).toEqual([]); }); it("reports exactly the retained verifier gaps and no unbacked prose mention", () => { @@ -372,10 +363,6 @@ describe("the self-hosting corpus", () => { expect([...firstSentence.matchAll(/`([a-z]+\/[a-z-]+)`/gu)].map((match) => match[1])).toEqual([ ...new Set(expectedWarnings.map(({ validatorId }) => validatorId)), ]); - // The extraction warnings print on the same step's build line, so the paragraph names them too. - for (const { validatorId, file } of result.report.findings) { - expect(paragraph).toContain(`\`${validatorId}\` names the \`${file ?? ""}\` link`); - } }); // The registry's preamble: a decision that narrows another refines it, and the narrowed row diff --git a/test/self-hosting-oracle/extraction.ts b/test/self-hosting-oracle/extraction.ts index b966eb420..5eb765c22 100644 --- a/test/self-hosting-oracle/extraction.ts +++ b/test/self-hosting-oracle/extraction.ts @@ -126,7 +126,7 @@ export const extractionSpecs = [ }, behavior: { rules: [ - "Discovery reads Spec and Pack carriers by the suffixes `.sdp.md`, `.sdp.gherkin` and `.sdp.ts`. Other `.ts` and `.tsx` files are anchor candidates, except `.d.ts` files. Discovery never descends into `node_modules`, `dist`, `generated`, `coverage` or a dot-directory, and configured exclusions apply to both lists. Discovery never follows a symbolic link below the root; each link found outside an excluded or skipped path gets one warning naming it; a root named explicitly is resolved to its real directory first.", + "Discovery reads Spec and Pack carriers by the suffixes `.sdp.md`, `.sdp.gherkin` and `.sdp.ts`. Other `.ts` and `.tsx` files are anchor candidates, except `.d.ts` files. Discovery never descends into `node_modules`, `dist`, `generated`, `coverage` or a dot-directory, and configured exclusions apply to both lists. Discovery never follows a symbolic link below the root; outside excluded or skipped paths, a link gets one warning naming it only if it targets a directory or its name matches a carrier or anchor-candidate filename, and other file links are silent; a root named explicitly is resolved to its real directory first.", "An exclusion is an exact root-relative POSIX path prefix applied to Spec-carrier and anchor-candidate discovery; a repeated exclusion counts once.", "A prefix excludes itself and slash-delimited descendants only; it never excludes a merely similar sibling path.", "Empty, absolute, Windows-drive, backslash and trailing-slash paths, and paths with an empty, `.` or `..` segment, are refused rather than normalized into a different meaning.", From 53ad8d9b044d658a93603b0ea48cb84b2c6f7292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Tue, 3 Nov 2026 20:17:58 +0100 Subject: [PATCH 12/16] fix(studio): label gathered intents and preview the hand-off memo Q6a: each gathered row reads ` : `, per #ui.gatheredIntents; the compose dialog and the panel share one label map, and change-pack gets its own label. Q6b: new rule #memoPreview and UI entry memoPreview; the panel shows a closed, read-only `Preview memo` of the memo it copies and saves, before the copy and save actions. Carries out the owner's rulings on the whole-repository review (PR #38); the diff was reviewed by the other model family. Co-Authored-By: Claude Opus 5.5 --- .../consumers/spec-studio.intent-panel.sdp.md | 2 + src/projections/studio/studio.css | 3 +- src/studio-elements/compose.ts | 11 +-- src/studio-elements/intent-panel-element.ts | 16 ++++- src/studio-elements/intent-panel.ts | 14 ++++ test/self-hosting-oracle/consumers.ts | 12 ++-- test/studio-s5.test.ts | 72 +++++++++++++++++++ 7 files changed, 113 insertions(+), 17 deletions(-) diff --git a/specs/consumers/spec-studio.intent-panel.sdp.md b/specs/consumers/spec-studio.intent-panel.sdp.md index c9bc578fe..22cad15a8 100644 --- a/specs/consumers/spec-studio.intent-panel.sdp.md +++ b/specs/consumers/spec-studio.intent-panel.sdp.md @@ -28,6 +28,7 @@ The change-proposal contract requires the commit whose graph the person read, an - rule: A composed intent names its scope by id or entry address, so the agent that receives it reads the same Spec, Pack, entry or question through the graph. - rule: [#handOff] The panel hands its gathered intents off as one change proposal, on the change-proposal contract, and as the memo recipe 49 renders for it: the person copies the memo or the proposal as text, or saves either as a file. The page opens no pull request, because a static page holds no credentials; any pull request comes from the agent's ordinary source edit. - rule: [#sameMemo] The memo the panel writes for a proposal is byte for byte the memo recipe 49 returns for the same proposal. +- rule: [#memoPreview] Whenever the panel offers the memo, it shows a closed, read-only preview of the same text `Copy memo` copies and `Save memo` saves, so the person can read the hand-off before it leaves. - rule: [#proposalBase] A proposal's base is the commit a present build stamp names, otherwise one the person supplies, and the person gives one reason for the whole proposal; without a base and a reason the panel exports its intents as scoped-intent text, and the agent that receives them writes the proposal against a commit it knows. - rule: [#packScope] A request scoped to a Pack records the members this page set renders for that Pack, and expands to them at hand-off only when the proposal's base is the commit the page set's stamp names; otherwise it stays scoped-intent text for an agent to resolve against the base. A request that changes a Pack's membership or framing, names an empty Pack or names a Pack with an unresolved member, stays scoped-intent text too, so the change-proposal contract keeps its Spec and entry scopes. - rule: [#textApart] The proposal holds only the intents its contract can carry; every intent that stays scoped-intent text is listed apart under `Not in the proposal`, and `Copy scoped intent` copies those intents, so the panel drops none. The copied text names each intent's action and recorded scope: the member Spec ids for a member change, with the Pack id as context, or the Pack id for a membership or framing change. @@ -37,6 +38,7 @@ The change-proposal contract requires the commit whose graph the person read, an - gatheredIntents: the intents gathered so far, headed `Composing` with their count, each on one line naming its scope and the change it asks for, such as adding an example or changing a constraint's target, with a button that discards it. - baseAndReason: the base as code when a stamp names it, otherwise a labelled text field for a commit, then a labelled text field for the proposal's reason. - panelActions: `Copy memo`, `Copy proposal`, `Save memo`, `Save proposal` and `Discard all`, the copy and save actions shown only when a base and a reason are present; and `Copy scoped intent` whenever an intent stays scoped-intent text, every intent without a base and a reason, and otherwise those the proposal cannot hold. +- memoPreview: a closed disclosure headed `Preview memo`, before the copy and save actions and shown with them, holding the memo as preformatted text that the person reads and cannot edit. - composingActions: the actions on other panels that compose intent: propose new text for a keyed entry, resolve an open question or promote it to a decision record, propose or edit an example, propose or change a constraint target, and propose examples for values no example binds. - composeDialog: a native dialog with a labelled text area for the request, a second for the exact entry text when the action proposes one, and `Gather` and `Cancel` buttons. diff --git a/src/projections/studio/studio.css b/src/projections/studio/studio.css index a32f624c9..69bd51731 100644 --- a/src/projections/studio/studio.css +++ b/src/projections/studio/studio.css @@ -834,7 +834,8 @@ textarea:focus-visible { gap: 0.75rem; } .intent-actions h3, -.intent-actions ul { +.intent-actions ul, +.intent-actions details { flex-basis: 100%; } dialog { diff --git a/src/studio-elements/compose.ts b/src/studio-elements/compose.ts index 8a9d93fd7..cafd58c53 100644 --- a/src/studio-elements/compose.ts +++ b/src/studio-elements/compose.ts @@ -1,15 +1,6 @@ import { button } from "./dom.js"; import { gatherInPage } from "./intent-panel-element.js"; -const labels: Readonly> = { - "resolve-question": "Resolve question", - "promote-question": "Propose decision record", - "edit-example": "Edit example", - "propose-example": "Propose example", - "change-target": "Change target", - "propose-target": "Propose target", - "propose-values": "Propose examples for uncovered values", - "change-entry": "Propose new text", -}; +import { composeLabels as labels } from "./intent-panel.js"; /** * @sdpAnchor impl:protocol.studio-compose * @sdpLabel opens a native request dialog beside build-rendered scopes without changing their content diff --git a/src/studio-elements/intent-panel-element.ts b/src/studio-elements/intent-panel-element.ts index c9c7afcd5..1a8162a69 100644 --- a/src/studio-elements/intent-panel-element.ts +++ b/src/studio-elements/intent-panel-element.ts @@ -1,5 +1,5 @@ /// -import { IntentPanel, pageRootPath } from "./intent-panel.js"; +import { IntentPanel, actionLabel, pageRootPath } from "./intent-panel.js"; import type { GatheredIntent, IntentStorage } from "./intent-panel.js"; import { button } from "./dom.js"; import { copyText } from "./copy.js"; @@ -75,6 +75,7 @@ export function registerIntentPanel(): void { if (!this.model) return; const model = this.model; const open = this.querySelector("details")?.open ?? false; + let previewOpen = this.querySelector(".memo-preview")?.open ?? false; this.releaseUrls(); this.replaceChildren(); const doc = this.ownerDocument; @@ -127,7 +128,7 @@ export function registerIntentPanel(): void { const row = doc.createElement("li"); const scope = doc.createElement("code"); scope.textContent = intent.pack ?? intent.scope.join(", "); - row.append(scope, ` ${intent.request} `); + row.append(scope, ` ${actionLabel(intent.action)}: ${intent.request} `); const discard = button(this, "Discard"); discard.setAttribute("aria-label", `Discard intent ${String(index + 1)}`); discard.addEventListener("click", () => { @@ -172,10 +173,21 @@ export function registerIntentPanel(): void { actions.className = "intent-actions"; body.append(actions, output); const refreshActions = (): void => { + previewOpen = + actions.querySelector(".memo-preview")?.open ?? previewOpen; this.releaseUrls(); actions.replaceChildren(); const handoff = model.handoff(stamped ?? this.base, this.reason, stamped); if (handoff.proposal && handoff.memo !== null) { + const preview = doc.createElement("details"); + preview.className = "memo-preview"; + preview.open = previewOpen; + const summary = doc.createElement("summary"); + summary.textContent = "Preview memo"; + const memo = doc.createElement("pre"); + memo.textContent = handoff.memo; + preview.append(summary, memo); + actions.append(preview); const documents = [ ["memo", handoff.memo, "proposal.md", "text/markdown"], [ diff --git a/src/studio-elements/intent-panel.ts b/src/studio-elements/intent-panel.ts index 29c5b4f51..839563986 100644 --- a/src/studio-elements/intent-panel.ts +++ b/src/studio-elements/intent-panel.ts @@ -36,6 +36,20 @@ function validIntent(value: unknown): value is GatheredIntent { ) ); } +export const composeLabels: Readonly> = { + "resolve-question": "Resolve question", + "promote-question": "Propose decision record", + "edit-example": "Edit example", + "propose-example": "Propose example", + "change-target": "Change target", + "propose-target": "Propose target", + "propose-values": "Propose examples for uncovered values", + "change-entry": "Propose new text", +}; +export function actionLabel(action: string): string { + if (action === "change-pack") return "Change Pack membership or framing"; + return composeLabels[action] ?? action; +} export function pageRootPath(root: string, page: string): string { return new URL(root || "./", page).pathname; } diff --git a/test/self-hosting-oracle/consumers.ts b/test/self-hosting-oracle/consumers.ts index e4a4bdada..7382df792 100644 --- a/test/self-hosting-oracle/consumers.ts +++ b/test/self-hosting-oracle/consumers.ts @@ -1850,6 +1850,7 @@ export const consumersSpecs = [ "A composed intent names its scope by id or entry address, so the agent that receives it reads the same Spec, Pack, entry or question through the graph.", "The panel hands its gathered intents off as one change proposal, on the change-proposal contract, and as the memo recipe 49 renders for it: the person copies the memo or the proposal as text, or saves either as a file. The page opens no pull request, because a static page holds no credentials; any pull request comes from the agent's ordinary source edit.", "The memo the panel writes for a proposal is byte for byte the memo recipe 49 returns for the same proposal.", + "Whenever the panel offers the memo, it shows a closed, read-only preview of the same text `Copy memo` copies and `Save memo` saves, so the person can read the hand-off before it leaves.", "A proposal's base is the commit a present build stamp names, otherwise one the person supplies, and the person gives one reason for the whole proposal; without a base and a reason the panel exports its intents as scoped-intent text, and the agent that receives them writes the proposal against a commit it knows.", "A request scoped to a Pack records the members this page set renders for that Pack, and expands to them at hand-off only when the proposal's base is the commit the page set's stamp names; otherwise it stays scoped-intent text for an agent to resolve against the base. A request that changes a Pack's membership or framing, names an empty Pack or names a Pack with an unresolved member, stays scoped-intent text too, so the change-proposal contract keeps its Spec and entry scopes.", "The proposal holds only the intents its contract can carry; every intent that stays scoped-intent text is listed apart under `Not in the proposal`, and `Copy scoped intent` copies those intents, so the panel drops none. The copied text names each intent's action and recorded scope: the member Spec ids for a member change, with the Pack id as context, or the Pack id for a membership or framing change.", @@ -1860,10 +1861,11 @@ export const consumersSpecs = [ persist: "rules[3]", handOff: "rules[5]", sameMemo: "rules[6]", - proposalBase: "rules[7]", - packScope: "rules[8]", - textApart: "rules[9]", - noRequest: "rules[10]", + memoPreview: "rules[7]", + proposalBase: "rules[8]", + packScope: "rules[9]", + textApart: "rules[10]", + noRequest: "rules[11]", }, exampleSpace: { given: ["the composing panel holds {gathered:number} gathered intents"], @@ -1888,6 +1890,8 @@ export const consumersSpecs = [ "the base as code when a stamp names it, otherwise a labelled text field for a commit, then a labelled text field for the proposal's reason.", panelActions: "`Copy memo`, `Copy proposal`, `Save memo`, `Save proposal` and `Discard all`, the copy and save actions shown only when a base and a reason are present; and `Copy scoped intent` whenever an intent stays scoped-intent text, every intent without a base and a reason, and otherwise those the proposal cannot hold.", + memoPreview: + "a closed disclosure headed `Preview memo`, before the copy and save actions and shown with them, holding the memo as preformatted text that the person reads and cannot edit.", composingActions: "the actions on other panels that compose intent: propose new text for a keyed entry, resolve an open question or promote it to a decision record, propose or edit an example, propose or change a constraint target, and propose examples for values no example binds.", composeDialog: diff --git a/test/studio-s5.test.ts b/test/studio-s5.test.ts index 9978c254c..9766bdca2 100644 --- a/test/studio-s5.test.ts +++ b/test/studio-s5.test.ts @@ -1034,6 +1034,78 @@ it("uses the stamp base, labels fields, orders actions and copies and saves the expect(dom.copied).toEqual([memo, document]); }); +it("names each gathered intent's scope and the change it asks for", () => { + // spec:consumers.spec-studio.intent-panel#ui.gatheredIntents + const dom = studioDom(assets.script, "https://studio.test/index.html", storage()); + const panel = dom.mount("sdp-intent-panel", { "page-root": "./" }); + panel.gather?.({ + scope: ["spec:probe.s1"], + request: "Cover the empty cart", + action: "propose-example", + }); + panel.gather?.({ + scope: ["spec:probe.s2"], + request: "Tighten the bound", + action: "change-target", + }); + const framing = { + scope: ["spec:probe.s1", "spec:probe.s2"], + pack: "pack:probe", + request: "Reframe the Pack", + action: "change-pack", + }; + panel.gather?.(framing); + expect( + panel + .querySelector("ol") + ?.querySelectorAll("li") + .map((row) => row.textContent), + ).toEqual([ + "spec:probe.s1 Propose example: Cover the empty cart Discard", + "spec:probe.s2 Change target: Tighten the bound Discard", + "pack:probe Change Pack membership or framing: Reframe the Pack Discard", + ]); +}); + +it("previews the memo it hands off, closed and read-only, before the copy actions", async () => { + // spec:consumers.spec-studio.intent-panel#rule.memoPreview + const dom = studioDom(assets.script, "https://studio.test/index.html", storage()); + dom.mount("sdp-stamp", { "data-corpus-commit": "abc" }); + const panel = dom.mount("sdp-intent-panel", { "page-root": "./" }); + panel.gather?.({ scope: ["spec:probe.s1"], request: "Read this", action: "change-entry" }); + const preview = () => panel.querySelector(".memo-preview"); + expect(preview()).toBeNull(); + const reason = panel.querySelector("input"); + if (!reason) throw new Error("Missing reason field"); + reason.value = "Review this design"; + reason.dispatch("input"); + const memo = (text: string) => + renderMemo({ + schema: "sdp-proposal/1", + base: "abc", + reason: text, + changes: [{ scope: ["spec:probe.s1"], request: "Read this" }], + }); + const shown = preview(); + expect(shown?.tagName).toBe("details"); + expect(shown?.open).toBe(false); + expect(shown?.querySelector("summary")?.textContent).toBe("Preview memo"); + expect(shown?.querySelector("pre")?.textContent).toBe(memo("Review this design")); + expect(shown?.querySelectorAll("textarea, input, button")).toHaveLength(0); + expect(panel.querySelector(".intent-actions")?.children[0]).toBe(shown); + panel + .querySelectorAll("button") + .find((b) => b.textContent === "Copy memo") + ?.dispatch("click"); + await Promise.resolve(); + expect(dom.copied).toEqual([shown?.querySelector("pre")?.textContent]); + if (shown) shown.open = true; + reason.value = "Review this design again"; + reason.dispatch("input"); + expect(preview()?.open).toBe(true); + expect(preview()?.querySelector("pre")?.textContent).toBe(memo("Review this design again")); +}); + it("copies distinct actions and recorded scopes for both Pack choices without a stamp", () => { // spec:consumers.spec-studio.intent-panel#rule.textApart const copies = ["members", "pack"].map((choice) => { From 4b3c485f1bccf46b929f12cff8387929ce83e30a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Tue, 3 Nov 2026 20:18:14 +0100 Subject: [PATCH 13/16] specs(studio): rule that a Pack export leaves the slice page out SP1: the slice page's open question #exportCarriesSlice becomes rule #exportOmitsSlice; the export already behaved this way, and a new test pins it. Carries out the owner's rulings on the whole-repository review (PR #38); the diff was reviewed by the other model family. Co-Authored-By: Claude Opus 5.5 --- specs/consumers/spec-studio.slice-page.sdp.md | 7 +------ test/self-hosting-oracle/consumers.ts | 12 ++---------- test/studio-slice.test.ts | 13 +++++++++++++ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/specs/consumers/spec-studio.slice-page.sdp.md b/specs/consumers/spec-studio.slice-page.sdp.md index cd47eb434..7dbdec865 100644 --- a/specs/consumers/spec-studio.slice-page.sdp.md +++ b/specs/consumers/spec-studio.slice-page.sdp.md @@ -33,12 +33,6 @@ The Pack page reads a Pack card by card, in the order its author wrote. A review - outcome: Give the Studio a page per Pack that shows its design slice: the members' dependency drawing, the components they touch, the decisions and open questions in scope, and each member's next rung. -### Open questions - -- [non-blocking #exportCarriesSlice] Does a Pack export also carry the Pack's slice page? - - decider: delegated - - provisional: no; the export keeps the Pack page and its members, and the slice page stays one link away in the page set - ## Behavior - rule: [#slicePerPack] The page set holds one slice page per Pack, `slices/.html`, its name formed from the Pack's id as the Pack page's name is; it renders the build's design slice answer for that Pack in full and states no value the answer does not hold, apart from counts the build takes of it. @@ -58,6 +52,7 @@ The Pack page reads a Pack card by card, in the order its author wrote. A review - rule: [#boundary] Under `Where the slice meets the rest` come four closed sections, Rests on, Rested on by, Verified by and Superseded by, each headed with its count; a Rests on row shows the outside Spec linked, its stated rung beside the floor reached, its implementation binding as present or none, the members it joins under each relation type, and the members it holds. - rule: [#bindingWords] The page speaks binding language: a unit implements or references a member, a verifier is bound, and no cell reads built, done, verified or approved. - rule: [#openAll] `Open all` and `Close all` open or close every closed section and text equivalent at once, and the page reads in full without script. +- rule: [#exportOmitsSlice] A Pack export carries the Pack page and its members' pages and not the slice page, which stays one link from the Pack page in the page set. ## UI diff --git a/test/self-hosting-oracle/consumers.ts b/test/self-hosting-oracle/consumers.ts index 7382df792..65a37a9a6 100644 --- a/test/self-hosting-oracle/consumers.ts +++ b/test/self-hosting-oracle/consumers.ts @@ -7242,16 +7242,6 @@ export const consumersSpecs = [ intent: { outcome: "Give the Studio a page per Pack that shows its design slice: the members' dependency drawing, the components they touch, the decisions and open questions in scope, and each member's next rung.", - openQuestions: [ - { - question: "Does a Pack export also carry the Pack's slice page?", - blocking: false, - key: "exportCarriesSlice", - decider: "delegated", - provisional: - "no; the export keeps the Pack page and its members, and the slice page stays one link away in the page set", - }, - ], }, behavior: { rules: [ @@ -7272,6 +7262,7 @@ export const consumersSpecs = [ "Under `Where the slice meets the rest` come four closed sections, Rests on, Rested on by, Verified by and Superseded by, each headed with its count; a Rests on row shows the outside Spec linked, its stated rung beside the floor reached, its implementation binding as present or none, the members it joins under each relation type, and the members it holds.", "The page speaks binding language: a unit implements or references a member, a verifier is bound, and no cell reads built, done, verified or approved.", "`Open all` and `Close all` open or close every closed section and text equivalent at once, and the page reads in full without script.", + "A Pack export carries the Pack page and its members' pages and not the slice page, which stays one link from the Pack page in the page set.", ], ruleKeys: { slicePerPack: "rules[0]", @@ -7291,6 +7282,7 @@ export const consumersSpecs = [ boundary: "rules[14]", bindingWords: "rules[15]", openAll: "rules[16]", + exportOmitsSlice: "rules[17]", }, }, ui: { diff --git a/test/studio-slice.test.ts b/test/studio-slice.test.ts index 7ed2b2b7c..56c22f601 100644 --- a/test/studio-slice.test.ts +++ b/test/studio-slice.test.ts @@ -252,6 +252,19 @@ describe("slice page", () => { for (const p of pages.filter((p) => p.path.startsWith("exports/"))) expect(p.content).not.toMatch(/\.\.\//u); }); + it("leaves the slice page out of the Pack export", () => { + // spec:consumers.spec-studio.slice-page#rule.exportOmitsSlice + const { pages, data } = shared; + expect(page(pages)).toContain("Members read with the ones others rest on first."); + const exported = page(pages, "exports/probe._slice.html"); + const pack = required(data.packs.find((p) => p.context.id === "pack:probe.Slice")).context; + expect([...exported.matchAll(/
m[1])).toEqual([ + pack.id, + ...pack.members.map((m) => m.id), + ]); + expect(text(exported)).not.toContain("Design slice"); + expect(exported).not.toContain("Members read with the ones others rest on first."); + }); it("names each manifest member that resolves to no Spec beside the summary", async () => { const root = mkdtempSync(join(tmpdir(), "sdp-slice-missing-")); roots.push(root); From c70bf10494a900e432e38ac12dc8f2fbf2a53c52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Tue, 3 Nov 2026 20:18:57 +0100 Subject: [PATCH 14/16] evidence: record the arc's results and open items The owner ruled on all six of the review's questions (PR #38); this arc carries the rulings out. The README now lists lanes S and L, a results table per lane, and what stays open: L1-03, and R6, R8 and the plan-46 prototypes for the cleanup arc. Co-Authored-By: Claude Opus 5.5 --- evidence/pr39/README.md | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/evidence/pr39/README.md b/evidence/pr39/README.md index 0a127ebbb..1effc0157 100644 --- a/evidence/pr39/README.md +++ b/evidence/pr39/README.md @@ -18,13 +18,38 @@ lane is merged into this branch, and the full gate runs once over the merged res | H2 verification runtime | L1-01, L1-02, L5-01, L5-02 | GPT | Claude | | P CLI and package polish | L2-3, L2-5, L3-03, L4-03, L9-F2, C1 | Claude | GPT | | D docs and vocabulary | L6-1, L6-4, L6-6, L2-6, L9-F1, L9-F4, L9-F5 | Claude | GPT | +| S Studio rulings | Q6a, Q6b, the slice-page rule | Claude | GPT | +| L link-warning scope | Q1n, narrowing Q1's link warning | GPT | Claude | + +## Results + +The owner ruled on all six questions the review raised (see PR #38), and this arc carries the +rulings out. Lanes S and L form a second pass that carries out the rulings the first six lanes +left: Q6's code half, the slice-page rule, and a narrowing of Q1's link warning. + +| Lane | Items | Outcome | +| ---- | ------------------------------------------- | ----------------------------------------------------------------- | +| C | P1–P7, Q1–Q6, K1–K3 | applied or adapted; Q6's code half moved to lane S | +| W | L4-01, L4-04, L4-02, Q1 | fixed; L4-04 adapted to the watch root | +| H1 | L3-01, L1-03, L1-04, L1-05 | fixed, except L1-03 (open, below) | +| H2 | L1-01, L1-02, L5-01, L5-02 | fixed | +| P | L2-3, L2-5, L3-03, L4-03, L9-F2, C1 | fixed | +| D | L6-1, L6-4, L6-6, L2-6, L9-F1, L9-F4, L9-F5 | fixed | +| S | Q6a, Q6b, SP1 | fixed (Q6a, Q6b); applied (SP1, a rule with a new test) | +| L | Q1n | fixed; a delegated narrowing of Q1 that awaits the owner's review | + +## Still open + +- **L1-03**, a `kind` slot in a Then step. The choice is between reserving `kind` in Then slots + and changing the Outcome payload representation. The owner's third ruling chose the + reservation; no lane carries it out yet. +- **R6** (its re-entry conditions, or dropping it), **R8** (its home), and **the plan-46 + prototypes** (their new location) belong to the cleanup arc. ## Out of scope - **The inventory's record findings** wait for the owner's review and the delivery-record cleanup. Nothing in `plans/` or `reviews/` is deleted here. -- **Owner questions are authored, not answered.** The symbolic-link discovery policy (L3-02) - stays open, so its code change waits. - **The concept-document drift** (L6-2, D1, L6-5) belongs to the cleanup, which dissolves those documents. - **The article corrections** are in PR #34. From 6780da5817be2ec3522119e1b705e898a8e8a6e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Tue, 3 Nov 2026 20:20:27 +0100 Subject: [PATCH 15/16] fix(carrier): reserve kind in Then slots - L1-03: a Then slot named `kind` collides with the flat Outcome discriminant. The slot-notation and executable-contracts Specs gain a keyed `#reservedThenSlot` rule; a new error-level conformance check, `conformance/reserved-then-slot`, reports it with a probe pair; contract generation withholds the affected space or step contract with a `contracts/reserved-then-slot` warning that names the reserved word. Given and When slots stay unrestricted and the Outcome stays flat. Carries out the owner's ruling on the whole-repository review (PR #38); the diff was reviewed by the other model family. Co-Authored-By: Claude Opus 5.5 --- specs/carrier/slot-notation.sdp.md | 1 + specs/extraction/executable-contracts.sdp.md | 3 +- src/codegen/contracts.ts | 22 +- src/notation/example-space.ts | 23 +++ src/validate/validators.ts | 23 +++ test/codegen.test.ts | 204 +++++++++++++++++++ test/helpers/validator-probes.ts | 25 +++ test/self-hosting-oracle/carrier.ts | 2 + test/self-hosting-oracle/extraction.ts | 4 +- 9 files changed, 303 insertions(+), 4 deletions(-) diff --git a/specs/carrier/slot-notation.sdp.md b/specs/carrier/slot-notation.sdp.md index 0b83f6e53..9cf7ae44a 100644 --- a/specs/carrier/slot-notation.sdp.md +++ b/specs/carrier/slot-notation.sdp.md @@ -21,6 +21,7 @@ A closed union takes two or more literals because a dimension with one value var - [#oneLiteralBinding] A group holding one double-quoted literal, `{x: "only"}`, is a binding; in a vocabulary it declares no type, no one-value dimension, and contract generation reports `contracts/untyped-vocabulary-slot`. - [#bareGroup] A bare group, `{n}`, names a slot and binds nothing; in an example it is unbound, and in a vocabulary it declares no type. - [#localDegradation] Lexical degradation stays local: a stray or unterminated brace group is prose only up to the next candidate, so it never swallows a well-formed binding that follows it. +- [#reservedThenSlot] The slot name `kind` is reserved in every Then step, both in an example-space vocabulary and in a structured example. Graph validation reports `conformance/reserved-then-slot` at error severity, naming the reserved word. Given and When slots may still be named `kind`. ## Example space A bound value cannot hold a double quote, so a point writes each double quote its step text needs as a single quote, and the world restores them before it parses. The world also runs contract generation over a parent whose example space holds the restored step text as its one Given step; the generation finding is `nothing` when generation reports none. diff --git a/specs/extraction/executable-contracts.sdp.md b/specs/extraction/executable-contracts.sdp.md index e27453207..2f94241c4 100644 --- a/specs/extraction/executable-contracts.sdp.md +++ b/specs/extraction/executable-contracts.sdp.md @@ -31,6 +31,7 @@ relations: - rule: [#keyConflicts] A contract never holds two steps under one contract key. An example marker that disagrees with the key of its vocabulary step withholds the example's step contract with `contracts/conflicting-step-key`. A step or space contract whose steps would share a contract key, two skeletons under one key or a key equal to another step's skeleton, is withheld with `contracts/duplicate-step-key`, naming the key and both skeletons; a withheld space contract withholds every registrar of its children. - rule: [#caseCollision] Two contract paths differing only by letter case cannot coexist on a case-insensitive filesystem, so the contracts tree is withheld whole and the finding names the colliding pair. - rule: Every generation finding is a warning that describes what did not emit; gating belongs to graph validation alone, so a withheld contract never fails the build by itself. +- rule: [#reservedThenSlot] A Then slot named `kind` withholds its space contract when declared in a vocabulary, or its step contract when carried by an example, with `contracts/reserved-then-slot` naming the reserved word; a withheld space contract also withholds its children's registrars. Outcome variants keep their flat shape, with `kind` as the discriminant and the other slots as sibling fields. ## Example space ```gwt-vocabulary @@ -46,7 +47,7 @@ Then the findings name {findingId:string} ``` ## Design -- findingIds: `contracts/undeclared-slot` · `contracts/off-dimension-value` · `contracts/conflicting-binding` · `contracts/conflicting-dimension` · `contracts/untyped-vocabulary-slot` · `contracts/multi-entry-example` · `contracts/unmatched-vocabulary-step` · `contracts/case-colliding-path` · `contracts/multiple-registrar-paths` · `contracts/conflicting-step-key` · `contracts/duplicate-step-key`, each at warning severity. +- findingIds: `contracts/undeclared-slot` · `contracts/off-dimension-value` · `contracts/conflicting-binding` · `contracts/conflicting-dimension` · `contracts/untyped-vocabulary-slot` · `contracts/multi-entry-example` · `contracts/unmatched-vocabulary-step` · `contracts/case-colliding-path` · `contracts/multiple-registrar-paths` · `contracts/conflicting-step-key` · `contracts/duplicate-step-key` · `contracts/reserved-then-slot`, each at warning severity. - duplicateStepKey: `contracts/duplicate-step-key` is a warning on the parent at `behavior.exampleSpace` for a space collision, or on the example at `behavior.examples` for a step-contract collision. Its message is `contract key "" names both "" and ""; contract withheld`, with the skeletons in first-occurrence order. - contractStep: `export interface ContractStep { readonly kind: StepKind; readonly key: K; readonly text: string; readonly params: P; }` is the step shape the runner exports, where `key` is the contract key and `text` the skeleton the natural reading renders. - stepContractModule: `export type Step = "" | …;` is the union of the example's contract keys in first-occurrence order, `StepParams` maps each contract key to its parameters, and `export const Contract: ExampleContract` lists every step as `{ kind, key, text, params }` in authored order. diff --git a/src/codegen/contracts.ts b/src/codegen/contracts.ts index 0c1e0bc29..0cce3eed5 100644 --- a/src/codegen/contracts.ts +++ b/src/codegen/contracts.ts @@ -12,6 +12,7 @@ import { pointEntryOf, isCompleteEntry, decodeExampleSpaces, + reservedThenSlotsOf, } from "../notation/example-space.js"; import type { StepPhase, @@ -714,7 +715,24 @@ export function generateContracts(graph: GraphSchema): GeneratedContracts { ); const decoded = decodeExampleSpaces(graph); const spacesByParent = decoded.spaces; - const withheldSpaces = decoded.withheldSpaces; + const withheldSpaces = new Set(decoded.withheldSpaces); + const withheldExamples = new Set(); + for (const id of sortedIds) { + const node = primitives.get(id); + if (node === undefined) continue; + for (const { text, path } of reservedThenSlotsOf(node)) { + const isSpace = path.startsWith("behavior.exampleSpace."); + (isSpace ? withheldSpaces : withheldExamples).add(id); + findings.push( + contractsFinding( + contractsFindingIds.reservedThenSlot, + `Then slot "kind" is reserved for the Outcome discriminant in step "${text}"; ${isSpace ? "space" : "step"} contract withheld`, + node, + path, + ), + ); + } + } findings.push(...decoded.findings); const boundPoints = new Map(); const files = new Map(); @@ -729,7 +747,7 @@ export function generateContracts(graph: GraphSchema): GeneratedContracts { for (const id of sortedIds) { const node = primitives.get(id); - if (node?.specKind !== "example") { + if (node?.specKind !== "example" || withheldExamples.has(id)) { continue; } diff --git a/src/notation/example-space.ts b/src/notation/example-space.ts index 0e7c1f24e..1f86b159d 100644 --- a/src/notation/example-space.ts +++ b/src/notation/example-space.ts @@ -26,6 +26,7 @@ export const contractsFindingIds = { multipleRegistrarPaths: "contracts/multiple-registrar-paths", conflictingStepKey: "contracts/conflicting-step-key", duplicateStepKey: "contracts/duplicate-step-key", + reservedThenSlot: "contracts/reserved-then-slot", } as const; export function asRecord(value: unknown): Record | undefined { @@ -73,6 +74,28 @@ export function structuredEntriesOf(node: PrimitiveNode): readonly StructuredEnt return structured; } +/** Then payload fields share the flat Outcome object with its discriminant. */ +export function reservedThenSlotsOf( + node: PrimitiveNode, +): readonly { readonly text: string; readonly path: string }[] { + const behavior = asRecord(node.sections?.behavior); + const space = asRecord(behavior?.exampleSpace); + const examples = Array.isArray(behavior?.examples) ? behavior.examples : []; + const steps = [ + ...asStringArray(space?.then).map((text, index) => ({ + text, + path: `behavior.exampleSpace.then[${String(index)}]`, + })), + ...examples.flatMap((entry, entryIndex) => + asStringArray(asRecord(entry)?.then).map((text, stepIndex) => ({ + text, + path: `behavior.examples[${String(entryIndex)}].then[${String(stepIndex)}]`, + })), + ), + ]; + return steps.filter(({ text }) => parseSlots(text).some((slot) => slot.name === "kind")); +} + export interface VocabularyEntry { readonly key: string; readonly stepKey?: string; diff --git a/src/validate/validators.ts b/src/validate/validators.ts index 57378b91b..893447995 100644 --- a/src/validate/validators.ts +++ b/src/validate/validators.ts @@ -28,6 +28,7 @@ import { SPEC_SECTION_NAMES } from "../model/sections.js"; import { buildGraphIndex } from "./graph-index.js"; import type { GraphIndex } from "./graph-index.js"; import type { Finding, Severity, ValidationReport, ValidatorFamily } from "./contracts.js"; +import { reservedThenSlotsOf } from "../notation/example-space.js"; import { evaluateReadinessFloor } from "./readiness-floor.js"; /** @@ -51,6 +52,7 @@ export const graphValidatorIds = { orphans: "conformance/orphans", proseMentions: "conformance/prose-mentions", ruleExercises: "conformance/rule-exercises", + reservedThenSlot: "conformance/reserved-then-slot", authoringShape: "honesty/authoring-shape", deliveryFacts: "honesty/delivery-facts", readinessFloor: "honesty/readiness-floor", @@ -1899,6 +1901,26 @@ function checkSupersededDecisions(graph: GraphSchema, index: GraphIndex): readon return findings; } +/* ----- conformance/reserved-then-slot ----- */ + +function checkReservedThenSlots(graph: GraphSchema): readonly Finding[] { + return graph.nodes.flatMap((node) => + node.nodeType !== "Primitive" + ? [] + : reservedThenSlotsOf(node).map(({ text, path }) => + createFinding({ + validatorId: graphValidatorIds.reservedThenSlot, + family: "conformance", + severity: "error", + subjectId: node.id, + file: node.file, + path, + message: `Then slot "kind" is reserved for the Outcome discriminant in step "${text}"`, + }), + ), + ); +} + /* ----- the aggregate ----- */ function compareCodeUnits(left: string, right: string): number { @@ -1947,6 +1969,7 @@ export function validateGraph(graph: GraphSchema): ValidationReport { ...checkOrphans(graph, index), ...checkProseMentions(graph, index), ...checkRuleExercises(graph, index), + ...checkReservedThenSlots(graph), ...checkSupersededDecisions(graph, index), ...authoringShapeFindings, ...checkDeliveryFacts(graph, derivedFacts), diff --git a/test/codegen.test.ts b/test/codegen.test.ts index 9c04e2c62..5b2acccc4 100644 --- a/test/codegen.test.ts +++ b/test/codegen.test.ts @@ -19,6 +19,7 @@ import { evaluateBody, contractsFindingIds, generateContracts, + validateGraph, refines, spec, specId, @@ -947,3 +948,206 @@ describe("the contracts codegen stage", () => { expect(generated.files.size).toBe(0); }); }); + +describe("reserved Then slot kind", () => { + function carrierGraph(steps: string, vocabulary: boolean) { + const carrier = reifyMarkdownCarrier( + `--- +id: spec:probe.reserved-slot +kind: ${vocabulary ? "behavior" : "example"} +altitude: story +readiness: idea +relations: {} +--- +# Reserved slot probe +## Intent +- outcome: Check the Then slot reservation. +${vocabulary ? "## Example space\n```gwt-vocabulary" : "```gwt"} +${steps} +\`\`\` +`, + "probe.sdp.md", + ); + expect(carrier.findings).toEqual([]); + return deriveGraph(carrier.specs, [], []); + } + + for (const vocabulary of [true, false]) { + for (const marker of ["", "[#result] "]) { + it.each(["{kind:string}", '{kind: "accepted"}', "{kind}", "{kind: maybe}"])( + `reports conformance for ${vocabulary ? "vocabulary" : "example"} ${marker}%s`, + (slot) => { + const graph = carrierGraph( + `Given ready\nWhen run\nThen ${marker}result ${slot}`, + vocabulary, + ); + expect( + validateGraph(graph).findings.filter( + (finding) => finding.validatorId === "conformance/reserved-then-slot", + ), + ).toEqual([ + expect.objectContaining({ + family: "conformance", + severity: "error", + subjectId: "spec:probe.reserved-slot", + file: "probe.sdp.md", + path: vocabulary ? "behavior.exampleSpace.then[0]" : "behavior.examples[0].then[0]", + message: expect.stringContaining('Then slot "kind" is reserved') as unknown, + }), + ]); + }, + ); + } + + it(`refuses the ${vocabulary ? "space" : "step"} contract and names kind`, () => { + const graph = carrierGraph( + `Given ready\nWhen run\nThen result ${vocabulary ? "{kind:string}" : '{kind: "accepted"}'}`, + vocabulary, + ); + const generated = generateContracts(graph); + expect(generated.files.size).toBe(0); + expect(generated.findings).toEqual([ + expect.objectContaining({ + validatorId: "contracts/reserved-then-slot", + severity: "warning", + message: expect.stringContaining('Then slot "kind" is reserved') as unknown, + }), + ]); + }); + } + + it("checks later structured entries at their authored index", () => { + const graph = deriveFixtureGraph({ specs: [parent(), child(BOUND_GWT)] }); + const changed: GraphSchema = { + ...graph, + nodes: graph.nodes.map((node) => + node.nodeType === "Primitive" && node.specKind === "example" + ? { + ...node, + sections: { + ...node.sections, + behavior: { + examples: [ + "A prose example", + BOUND_GWT, + { ...BOUND_GWT, then: ['result {kind: "accepted"}'] }, + ], + }, + }, + } + : node, + ), + }; + expect(validateGraph(changed).findings).toContainEqual( + expect.objectContaining({ + validatorId: "conformance/reserved-then-slot", + path: "behavior.examples[2].then[0]", + }), + ); + expect(generateContracts(changed).files.has("orders.create-order.valid-cart.contract.ts")).toBe( + false, + ); + }); + + it("withholds registrars even when the child does not use the reserved vocabulary step", () => { + const base = deriveFixtureGraph({ + specs: [ + parent({ ...VOCABULARY, then: [...VOCABULARY.then, "result {kind:string}"] }), + child(BOUND_GWT), + ], + }); + const graph: GraphSchema = { + ...base, + nodes: [ + ...base.nodes, + { + id: "test:probe.reserved-slot", + nodeType: "Anchor", + claim: "anchored", + file: "test/probe.test.ts", + line: 1, + }, + ], + edges: [ + ...base.edges, + { + from: "test:probe.reserved-slot", + to: "spec:orders.create-order.valid-cart", + type: "verifies", + claim: "anchored", + }, + ], + }; + const generated = generateContracts(graph); + expect(generated.files.has("orders.create-order.space.ts")).toBe(false); + expect(generated.files.has("orders.create-order.valid-cart.contract.ts")).toBe(true); + expect(generated.registrars.size).toBe(0); + }); + + it.each(["result", "Kind", "kindOf", "_kind"])( + "keeps the flat Outcome for lawful slot %s", + (name) => { + const generated = generateContracts( + carrierGraph(`Given ready\nWhen run\nThen result {${name}:string}`, true), + ); + expect(generated.findings).toEqual([]); + expect(generated.files.get("probe.reserved-slot.space.ts")).toContain( + `{ readonly kind: "result {${name}}"; readonly ${name}: string }`, + ); + }, + ); + + it.each(["given", "when"] as const)( + "allows kind in %s and emits contracts that typecheck", + (phase) => { + const vocabulary = { given: ["ready"], when: ["run"], then: ["done"] }; + const bound = { ...vocabulary, [phase]: ['input {kind: "accepted"}'] }; + vocabulary[phase] = ["input {kind:string}"]; + const graph = deriveFixtureGraph({ specs: [parent(vocabulary), child(bound)] }); + expect( + validateGraph(graph).findings.filter( + (finding) => finding.validatorId === "conformance/reserved-then-slot", + ), + ).toEqual([]); + const generated = generateContracts(graph); + expect(generated.findings).toEqual([]); + expect(generated.files.size).toBe(2); + expect(generated.files.get("orders.create-order.space.ts")).toContain( + "readonly kind: string;", + ); + expect(generated.files.get("orders.create-order.valid-cart.contract.ts")).toContain( + 'params: { kind: "accepted" }', + ); + + const options: ts.CompilerOptions = { + noEmit: true, + strict: true, + skipLibCheck: true, + types: [], + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + paths: { + "@libar-dev/software-delivery-protocol/runner": [process.cwd() + "/src/runner/index.ts"], + }, + }; + const sources = new Map( + [...generated.files].map(([path, text]) => [process.cwd() + "/generated/" + path, text]), + ); + const host = ts.createCompilerHost(options); + const getSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (file, version, onError, shouldCreateNewSourceFile) => { + const text = sources.get(file); + return text === undefined + ? getSourceFile(file, version, onError, shouldCreateNewSourceFile) + : ts.createSourceFile(file, text, version, true); + }; + const program = ts.createProgram([...sources.keys()], options, host); + expect( + ts + .getPreEmitDiagnostics(program) + .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")), + ).toEqual([]); + }, + ); +}); diff --git a/test/helpers/validator-probes.ts b/test/helpers/validator-probes.ts index 054eb8d04..0baa4dc9a 100644 --- a/test/helpers/validator-probes.ts +++ b/test/helpers/validator-probes.ts @@ -190,6 +190,31 @@ const graphPairs: ProbeEntry[] = [ graph([subject, target], [relation("verifies", target.id, { exercises: ["unknown"] })]), graph([subject, target], [relation("verifies", target.id, { exercises: ["known"] })]), ), + graphPair( + graphValidatorIds.reservedThenSlot, + graph([ + primitive(subject.id, { + sections: { + ...subject.sections, + behavior: { + ...subject.sections?.behavior, + exampleSpace: { then: ["result {kind:string}"] }, + }, + }, + }), + ]), + graph([ + primitive(subject.id, { + sections: { + ...subject.sections, + behavior: { + ...subject.sections?.behavior, + exampleSpace: { then: ["result {kinds:string}"] }, + }, + }, + }), + ]), + ), graphPair( graphValidatorIds.authoringShape, { graph: authoringShape }, diff --git a/test/self-hosting-oracle/carrier.ts b/test/self-hosting-oracle/carrier.ts index 6cfed0395..24e8420a5 100644 --- a/test/self-hosting-oracle/carrier.ts +++ b/test/self-hosting-oracle/carrier.ts @@ -797,6 +797,7 @@ export const carrierSpecs = [ 'A group holding one double-quoted literal, `{x: "only"}`, is a binding; in a vocabulary it declares no type, no one-value dimension, and contract generation reports `contracts/untyped-vocabulary-slot`.', "A bare group, `{n}`, names a slot and binds nothing; in an example it is unbound, and in a vocabulary it declares no type.", "Lexical degradation stays local: a stray or unterminated brace group is prose only up to the next candidate, so it never swallows a well-formed binding that follows it.", + "The slot name `kind` is reserved in every Then step, both in an example-space vocabulary and in a structured example. Graph validation reports `conformance/reserved-then-slot` at error severity, naming the reserved word. Given and When slots may still be named `kind`.", ], ruleKeys: { slotName: "rules[0]", @@ -806,6 +807,7 @@ export const carrierSpecs = [ oneLiteralBinding: "rules[4]", bareGroup: "rules[5]", localDegradation: "rules[6]", + reservedThenSlot: "rules[7]", }, exampleSpace: { given: ["the step text {stepText:string}"], diff --git a/test/self-hosting-oracle/extraction.ts b/test/self-hosting-oracle/extraction.ts index b966eb420..5c8374e8d 100644 --- a/test/self-hosting-oracle/extraction.ts +++ b/test/self-hosting-oracle/extraction.ts @@ -390,6 +390,7 @@ export const extractionSpecs = [ "A contract never holds two steps under one contract key. An example marker that disagrees with the key of its vocabulary step withholds the example's step contract with `contracts/conflicting-step-key`. A step or space contract whose steps would share a contract key, two skeletons under one key or a key equal to another step's skeleton, is withheld with `contracts/duplicate-step-key`, naming the key and both skeletons; a withheld space contract withholds every registrar of its children.", "Two contract paths differing only by letter case cannot coexist on a case-insensitive filesystem, so the contracts tree is withheld whole and the finding names the colliding pair.", "Every generation finding is a warning that describes what did not emit; gating belongs to graph validation alone, so a withheld contract never fails the build by itself.", + "A Then slot named `kind` withholds its space contract when declared in a vocabulary, or its step contract when carried by an example, with `contracts/reserved-then-slot` naming the reserved word; a withheld space contract also withholds its children's registrars. Outcome variants keep their flat shape, with `kind` as the discriminant and the other slots as sibling fields.", ], exampleSpace: { given: [ @@ -413,11 +414,12 @@ export const extractionSpecs = [ outcomeIdentity: "rules[8]", keyConflicts: "rules[9]", caseCollision: "rules[10]", + reservedThenSlot: "rules[12]", }, }, design: { findingIds: - "`contracts/undeclared-slot` · `contracts/off-dimension-value` · `contracts/conflicting-binding` · `contracts/conflicting-dimension` · `contracts/untyped-vocabulary-slot` · `contracts/multi-entry-example` · `contracts/unmatched-vocabulary-step` · `contracts/case-colliding-path` · `contracts/multiple-registrar-paths` · `contracts/conflicting-step-key` · `contracts/duplicate-step-key`, each at warning severity.", + "`contracts/undeclared-slot` · `contracts/off-dimension-value` · `contracts/conflicting-binding` · `contracts/conflicting-dimension` · `contracts/untyped-vocabulary-slot` · `contracts/multi-entry-example` · `contracts/unmatched-vocabulary-step` · `contracts/case-colliding-path` · `contracts/multiple-registrar-paths` · `contracts/conflicting-step-key` · `contracts/duplicate-step-key` · `contracts/reserved-then-slot`, each at warning severity.", duplicateStepKey: '`contracts/duplicate-step-key` is a warning on the parent at `behavior.exampleSpace` for a space collision, or on the example at `behavior.examples` for a step-contract collision. Its message is `contract key "" names both "" and ""; contract withheld`, with the skeletons in first-occurrence order.', contractStep: From c7368f48754528b4303fae12a601d88da84e65c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Tue, 3 Nov 2026 20:26:40 +0100 Subject: [PATCH 16/16] evidence: record lane K L1-03 is ruled by the owner and carried out by lane K (6780da58): Then slots reserve `kind`. Lane H1's row keeps its finding that L1-03 needed a ruling; L1-03 leaves the still-open list. Co-Authored-By: Claude Opus 5.5 --- evidence/pr39/README.md | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/evidence/pr39/README.md b/evidence/pr39/README.md index 1effc0157..17fdaa6ee 100644 --- a/evidence/pr39/README.md +++ b/evidence/pr39/README.md @@ -20,29 +20,47 @@ lane is merged into this branch, and the full gate runs once over the merged res | D docs and vocabulary | L6-1, L6-4, L6-6, L2-6, L9-F1, L9-F4, L9-F5 | Claude | GPT | | S Studio rulings | Q6a, Q6b, the slice-page rule | Claude | GPT | | L link-warning scope | Q1n, narrowing Q1's link warning | GPT | Claude | +| K reserve-kind | L1-03, the owner's third ruling | GPT | Claude | ## Results The owner ruled on all six questions the review raised (see PR #38), and this arc carries the -rulings out. Lanes S and L form a second pass that carries out the rulings the first six lanes -left: Q6's code half, the slice-page rule, and a narrowing of Q1's link warning. +rulings out. Lanes S, L and K form a second pass that carries out the rulings the first six lanes +left: Q6's code half, the slice-page rule, a narrowing of Q1's link warning, and the owner's third +ruling on L1-03, which lane H1 had found needing a ruling. | Lane | Items | Outcome | | ---- | ------------------------------------------- | ----------------------------------------------------------------- | | C | P1–P7, Q1–Q6, K1–K3 | applied or adapted; Q6's code half moved to lane S | | W | L4-01, L4-04, L4-02, Q1 | fixed; L4-04 adapted to the watch root | -| H1 | L3-01, L1-03, L1-04, L1-05 | fixed, except L1-03 (open, below) | +| H1 | L3-01, L1-03, L1-04, L1-05 | fixed, except L1-03 (needed a ruling; carried out by lane K) | | H2 | L1-01, L1-02, L5-01, L5-02 | fixed | | P | L2-3, L2-5, L3-03, L4-03, L9-F2, C1 | fixed | | D | L6-1, L6-4, L6-6, L2-6, L9-F1, L9-F4, L9-F5 | fixed | | S | Q6a, Q6b, SP1 | fixed (Q6a, Q6b); applied (SP1, a rule with a new test) | | L | Q1n | fixed; a delegated narrowing of Q1 that awaits the owner's review | +| K | L1-03 | fixed; carries out the owner's third ruling (below) | + +## L1-03, carried out + +A Then slot named `kind` collided with the flat Outcome discriminant, so a legal carrier generated +an invalid Outcome type. Lane H1 found that it needed a ruling, between reserving `kind` in Then +slots and changing the Outcome payload's representation. The owner's third ruling chose the +reservation. Lane K carries it out in `6780da58` ("fix(carrier): reserve kind in Then slots"): GPT +implemented it, and Claude asked for changes once, then approved. + +- The slot-notation and executable-contracts Specs gain rule `#reservedThenSlot`. +- Graph validation reports `conformance/reserved-then-slot` at error severity, naming the + reserved word. Given and When slots may still be named `kind`. +- Contract generation withholds the affected space or step contract with a + `contracts/reserved-then-slot` warning that names the reserved word. The Outcome keeps its + flat shape. +- Proof: the `reserved Then slot kind` suite in `test/codegen.test.ts`, and the + `conformance/reserved-then-slot` probe pair in `test/helpers/validator-probes.ts`, which + `test/validator-audit.test.ts` runs. ## Still open -- **L1-03**, a `kind` slot in a Then step. The choice is between reserving `kind` in Then slots - and changing the Outcome payload representation. The owner's third ruling chose the - reservation; no lane carries it out yet. - **R6** (its re-entry conditions, or dropping it), **R8** (its home), and **the plan-46 prototypes** (their new location) belong to the cleanup arc.