diff --git a/.agents/skills/sdp-agent-surface/SKILL.md b/.agents/skills/sdp-agent-surface/SKILL.md index f77bcaf4..eb0cfeb1 100644 --- a/.agents/skills/sdp-agent-surface/SKILL.md +++ b/.agents/skills/sdp-agent-surface/SKILL.md @@ -99,14 +99,15 @@ checkout: `exec` resolves dependency binaries, while this package does not link own `node_modules/.bin`; an unresolved `sdp` can select macOS's unrelated binary. Do not invoke a global `sdp` either. -The public projection publishers are `sdp view`, `sdp census`, `sdp mermaid`, and `sdp gherkin`. -Each of them, like `sdp build` and `sdp validate`, first removes all four projection roots, so +The public projection publishers are `sdp view`, `sdp census`, `sdp mermaid`, `sdp gherkin`, +and `sdp studio`. +Each of them, like `sdp build` and `sdp validate`, first removes all five projection roots, so after one publisher runs only its own root is current (`spec:consumers.wholesale-view-rewrite#rule.buildInvalidates`). In this source checkout, `npm run generate:self-hosting` and `npm run check:self-hosting` preserve -and publish all four roots together. In check mode a prior tree must exist for each root; Design -Review checks repeat-render determinism, while census, Mermaid and Gherkin also compare the prior -tree's bytes with the current render. +and publish all five roots together. In check mode a prior tree must exist for each root; Design +Review checks repeat-render determinism, while census, Mermaid, Gherkin and Studio also compare +the prior tree's bytes with the current render. The catalog of ready-made bodies is `docs/agent-surface/recipes.md` in the Protocol repository and `node_modules/@libar-dev/software-delivery-protocol/docs/agent-surface/recipes.md` in an @@ -125,33 +126,36 @@ pnpm exec sdp q "$(cat node_modules/@libar-dev/software-delivery-protocol/dist/r ### Which recipe answers a task -| The task | Run | -| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| Pick work to build | the build backlog (recipe 1); the design backlog (recipe 33) for designed, unbuilt Specs | -| Find code ahead of intent | the drift alarm (recipe 2) | -| Read what a Spec guarantees and who verifies it | Spec guarantees (recipe 3) | -| Review a changeset | changed-file impact (recipe 4), then the mention audit (recipe 22) over its scope | -| Locate a term or a concept | concept search (recipe 6); entry search (recipe 23) for the entry and its line | -| Check a Spec's stated next rung before a readiness edit | promotion preflight (recipe 9); the lower ladder (recipe 11) across the corpus | -| Compare stated readiness with the floor | readiness divergence (recipe 7) | -| Read warnings, gaps and verifier health | orphans and gaps (recipe 8); declared versus enabled verifiers (recipe 10) | -| Review a Pack | the Pack review backbone (recipe 5), Pack design (recipe 29), references into a design (recipe 27) | -| List open questions | the open-question register (recipe 20) | -| See what a Spec rests on | dependency footing (recipe 21); dependency cycles (recipe 26) | -| Change a Spec others rest on | design-change impact (recipe 30); the planning slice (recipe 19) | -| Check addresses written outside the Specs | address resolution (recipe 25) | -| List the signatures two parties share | pinned declarations (recipe 24) | -| Read a component and its reach | component membership (recipe 12), uses fan-in and fan-out (recipe 13), structural neighborhood (recipe 14) | -| Measure structural and projection coverage | census structural coverage (recipe 15), the projection-coverage upper bound (recipe 16) | -| Read the architecture | the architecture map (recipe 17), architecture crossings (recipe 32) | -| Weigh the decisions that shape it | the decision map (recipe 18), the decision register (recipe 31) | -| Reuse an architecture value | roles, layers and contexts (recipe 28) | -| Find keyed rules no example exercises | rule coverage (recipe 34) | -| See what changed since a design pass | the design diff (recipe 35) | -| Compose a design pass's reading | the context bundle (recipe 36), the perspective filter (recipe 37) | -| Apply a change proposal | the proposal check (recipe 38) | -| Slim a Spec its bound code now carries | slimming candidates (recipe 39) | -| Find files and components the anchors miss | the curation report (recipe 40) | +| The task | Run | +| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| Pick work to build | the build backlog (recipe 1); the design backlog (recipe 33) for designed, unbuilt Specs | +| Find code ahead of intent | the drift alarm (recipe 2) | +| Read what a Spec guarantees and who verifies it | Spec guarantees (recipe 3) | +| Review a changeset | changed-file impact (recipe 4), then the mention audit (recipe 22) over its scope | +| Locate a term or a concept | concept search (recipe 6); entry search (recipe 23) for the entry and its line | +| Check a Spec's stated next rung before a readiness edit | promotion preflight (recipe 9); the lower ladder (recipe 11) across the corpus | +| Compare stated readiness with the floor | readiness divergence (recipe 7) | +| Read warnings, gaps and verifier health | orphans and gaps (recipe 8); declared versus enabled verifiers (recipe 10) | +| Review a Pack | the Pack review backbone (recipe 5), Pack design (recipe 29), references into a design (recipe 27) | +| List open questions | the open-question register (recipe 20) | +| See what a Spec rests on | dependency footing (recipe 21); dependency cycles (recipe 26) | +| Change a Spec others rest on | design-change impact (recipe 30); the planning slice (recipe 19) | +| Check addresses written outside the Specs | address resolution (recipe 25) | +| List the signatures two parties share | pinned declarations (recipe 24) | +| Read a component and its reach | component membership (recipe 12), uses fan-in and fan-out (recipe 13), structural neighborhood (recipe 14) | +| Measure structural and projection coverage | census structural coverage (recipe 15), the projection-coverage upper bound (recipe 16) | +| Read the architecture | the architecture map (recipe 17), architecture crossings (recipe 32) | +| Check a corpus's architecture rules | the architecture walk (recipe 42) | +| Compare planned components with their realization | planned against realized components (recipe 43) | +| Weigh the decisions that shape it | the decision map (recipe 18), the decision register (recipe 31) | +| Reuse an architecture value | roles, layers and contexts (recipe 28) | +| Find keyed rules no example exercises | rule coverage (recipe 34) | +| See what changed since a design pass | the design diff (recipe 35) | +| Compose a design pass's reading | the context bundle (recipe 36), the perspective filter (recipe 37) | +| Apply a change proposal | the proposal check (recipe 38) | +| Slim a Spec its bound code now carries | slimming candidates (recipe 39) | +| Find files and components the anchors miss | the curation report (recipe 40) | +| List a family of keyed values (limits, error messages, tables) | the key-family register (recipe 41) | For structural questions, use component membership, uses fan-in and fan-out, structural neighborhood, census structural coverage, and the projection-coverage upper bound (recipes 12-16). @@ -220,6 +224,13 @@ To iterate on a design pass after pass: carries: pinned declarations an implemented, verified Spec may drop, and the files and components the anchors miss, from an imports file `sdp build --imports` writes. +For corpus architecture, the architecture walk (recipe 42) reads forbidden layer pairs and +isolated contexts from `params.forbidden` and `params.isolated`, with optional `params.severity`. +Its `{ findings }` answer can run as a rules file under `sdp validate --rules`. Planned against +realized components (recipe 43) compares component design Specs with their satisfying anchors, +listing unrealized designs, differing fields and unreadable values. Neither recipe adds a Protocol +validator. Follow `sdp-authoring` for rules-file invocation and per-file parameter order. + Reach for the files only when you need the authored prose itself — the exact words to edit. ## The entry adapters @@ -267,10 +278,12 @@ rows, and `leftOut` naming what `detail: true` adds (changed-file impact, recipe recipe 29; the design diff, recipe 35). To filter any shipped answer, run it inside your own body: ```sh -pnpm --silent sdp:q "const answer = await (async () => { $(cat dist/recipes/29-pack-design.js) })(); return answer.members.filter((member) => member.blockingQuestions > 0)" --params '{"pack":"pack:design-iteration-v1"}' --json +pnpm --silent sdp:q "const answer = await (async (params) => { $(cat dist/recipes/29-pack-design.js) })({ pack: params.pack }); return answer.members.filter((member) => member.blockingQuestions > 0)" --params '{"pack":"pack:design-iteration-v1"}' --json ``` -Keep your own part of such a body free of `$` and backticks, because the shell expands them. +Pass the inner body only its recipe's parameters. An unknown parameter name replaces a shipped +recipe's whole answer with `{ unknownParams, accepts }`, listing the unknown names and the names +it takes. Keep your own part of such a body free of `$` and backticks, because the shell expands them. The graph is derived on every invocation, so a Spec you just authored is queryable immediately and no committed artifact answers in the graph's name. The sink writes nothing. It evaluates local diff --git a/.agents/skills/sdp-authoring/SKILL.md b/.agents/skills/sdp-authoring/SKILL.md index fb24cb23..85f64607 100644 --- a/.agents/skills/sdp-authoring/SKILL.md +++ b/.agents/skills/sdp-authoring/SKILL.md @@ -201,11 +201,25 @@ so it needs no extra tag, status key, or side table. passed; pass and fail stay in CI. Author no checked or verified status. - Derive a count, a register, or a review scope each time you need it: census counts from `sdp census`, open questions from recipe 20, one Spec's dependencies from recipe 21, prose - mentions from recipe 22, and a change's review scope from changed-file blast radius (recipe 4). + mentions from recipe 22, a family of Design and UI keys from the key-family register (recipe 41), + and a change's review scope from changed-file blast radius (recipe 4). In prose, name the recipe and leave the number out. -`sdp validate` checks these homes and nothing your project adds. Keep a project policy, such as a -required Design key or a naming rule, as a script in your own repository. +Run a project policy, such as a required Design key or a naming rule, as an operator-selected +rules file through `sdp validate --rules PATH [--params FORM]...`. The file is a local async +JavaScript body with `g`, `graph`, `report` and `params`, returning `{ findings }`, as +`spec:validation.corpus-rules` states. Each `--params` follows its own `--rules`; the query grammar +admits JSON, `@PATH` and `KEY=@PATH`, and repeatable parameters merge within that file only: + +```sh +pnpm exec sdp validate . --rules design/policy.js --params '{"requiredKey":"id"}' --rules node_modules/@libar-dev/software-delivery-protocol/dist/recipes/42-the-architecture-walk.js --params @design/architecture.json +``` + +Corpus findings name their rule and rules file and print apart from Protocol conformance and +honesty findings. The corpus summary also counts failed files; any Protocol error, corpus error +or failed rules file exits 1. Under `--watch`, each carrier cycle rereads the rules files, but a +rules-file edit alone starts no cycle. The architecture walk is recipe 42; planned against +realized components is recipe 43. These are corpus guidance, never Protocol validators. ### Say why a relation holds @@ -501,10 +515,10 @@ coverage-unknown for the Markdown file. This repository binds its own skills exa The build emits one registrar per bindable example, named `.test.generated.ts` and placed beside the authored suite whose `specTest` anchor verifies that example; the Spec ID owns the filename, so one consolidated suite can bind many examples without collisions. - Repository generation also publishes the independent Design Review, census, Mermaid, and - Gherkin-shaped read roots. Use the repository generate/check scripts to certify the complete - projection suite; do not treat the Gherkin read root as an authored carrier. In an adopter, - each publisher leaves only its own root current, because every build first removes all four; + Repository generation also publishes the independent Design Review, census, Mermaid, + Gherkin-shaped read roots, and Studio pages. Use the repository generate/check scripts to certify + the complete projection suite; do not treat the Gherkin read root as an authored carrier. In an adopter, + each publisher leaves only its own root current, because every build first removes all five; run the publisher for the root you need to read. 4. Author the oracle for the space and bind it with a top-level `specOracle` anchor whose `models` diff --git a/.agents/skills/sdp-sessions/SKILL.md b/.agents/skills/sdp-sessions/SKILL.md index dd6d993a..afa7f5ab 100644 --- a/.agents/skills/sdp-sessions/SKILL.md +++ b/.agents/skills/sdp-sessions/SKILL.md @@ -24,11 +24,11 @@ If the active pnpm binary cannot run this package-lock-based checkout, the equiv wrapper is `npm run --silent sdp:q -- ''`. Never fall through to a global `sdp`. When a session needs human-readable output, the public publishers are `view`, `census`, `mermaid`, -and `gherkin`. Each one's build first removes all four roots, so after one publisher runs only its -own root is current. The repository's generate/check scripts preserve and publish all four roots +`gherkin`, and `studio`. Each one's build first removes all five roots, so after one publisher runs +only its own root is current. The repository's generate/check scripts preserve and publish all five roots together. In check mode a prior tree must exist for each root; Design Review checks repeat-render -determinism, while census, Mermaid and Gherkin also compare the prior tree's bytes with the current -render. +determinism, while census, Mermaid, Gherkin and Studio also compare the prior tree's bytes with +the current render. An adopter selects its root and exclusions through its installed package runner: @@ -62,7 +62,9 @@ Resolve the target's blocking open questions and review the carrying Specs. The register (recipe 20) lists every open question in the corpus when the neighbors matter too. A clear floor is evidence, not an automatic `ready` statement. Use pinned declarations (recipe 24) to list every signature, type, validator or table the target and its neighbors pin as a code span -opening a keyed Design entry, so a test author and a builder working apart read one list. When the +opening a keyed Design entry, so a test author and a builder working apart read one list. The +key-family register (recipe 41) lists every Design and UI entry whose key starts with a prefix +you give, such as `limit` or `error`, across the corpus, a Pack or a list of Specs. When the design is a Pack, read it with Pack design (recipe 29): per member, the stated next rung and what holds the member below it, the design columns, open questions with their lines, and the code that answers to it. Before changing a Spec, run design-change impact (recipe 30) for every Spec that @@ -110,7 +112,12 @@ one value. Pass the mention audit the pairs your corpus means to leave as prose they report apart from missing relations. To review a Pack's design as it stands, use Pack design (recipe 29); to review decisions, the decision register (recipe 31), with each decision's rung, supersession, refinement both ways with the reasons the relations state, and the Specs it shapes; to review dependency direction, architecture crossings (recipe 32), which reports the -`uses` edges that cross a context or layer and refuses nothing. Review findings and gaps as data; +`uses` edges that cross a context or layer and refuses nothing. For a corpus's own architecture +rules, run the architecture walk (recipe 42), which reports forbidden layer pairs and isolated +contexts, with unstated layers kept as unclassified. Planned against realized components +(recipe 43) lists component designs whose anchors are absent or differ. A corpus may select the +walk as a rules file under `sdp validate --rules`; follow `sdp-authoring` for per-file parameters +and the separate corpus findings. Review findings and gaps as data; the review never becomes a workflow gate. Report a review as a numbered list, most severe first. Each item names one entry address or Spec diff --git a/.git-archive-commit b/.git-archive-commit new file mode 100644 index 00000000..6828f88d --- /dev/null +++ b/.git-archive-commit @@ -0,0 +1 @@ +$Format:%H$ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..15ea1604 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.git-archive-commit export-subst diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e323da41..7e778b05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,11 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + # The check gate builds the package. Skip prepare here to avoid building it twice. + run: npm ci --ignore-scripts - name: Check run: npm run check + + - name: Install the committed package + run: npm run check:git-install diff --git a/AGENTS.md b/AGENTS.md index 4c5c1008..fbaee157 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,10 +16,16 @@ authoritative for what the Protocol claims; **`src/` and tests** are authoritati current realization. A disagreement is **drift to resolve**, never permission to silently promote code behavior into intent. -> **plan 44 is EXECUTED** on its branch. A whole-corpus refresh: every Spec read against the +> **plan 45 is EXECUTED** on `feature/design-at-scale-v0`, as PR 33. Design at the adopter's +> scale: the adopter installs from a commit, the design-pass loop fits a large corpus +> (`declarations: "addresses"`, recipe 41), corpus rules run under `sdp validate --rules` with +> recipes 42 and 43, and the Spec Studio's phase 0 publishes static pages (`sdp studio`; plan 42 +> was its brief). The first adopter moved onto it in its own branch. The rulings were delegated +> and await the owner's review; the plan lists them, the acceptance as measured, and what waits. +> **plan 44 is EXECUTED**, merged as PR 32. A whole-corpus refresh: every Spec read against the > engine, the other Specs and the vocabulary, cross-family verified, and the findings fixed, > ruled on the owner's delegation, or homed as keyed questions with provisional answers for the -> next arcs; the record is `reviews/26-whole-corpus-refresh.md`. Plan 42 follows it. +> next arcs; the record is `reviews/26-whole-corpus-refresh.md`. > **plan 43 is EXECUTED**, merged as PR 31. Design iteration on a readable graph: answers > that fit (summaries, both next rungs, explained misses, the design backlog, repeatable > `--params`), the authoring grammar (rule keys, question fields, relation reasons), the iteration @@ -27,9 +33,9 @@ code behavior into intent. > and slimming lessons; intent in `pack:design-iteration-v1`, recipes 33 to 40. The design pass's > rulings and `ready` statements were delegated by the owner and still await the owner's review; > the plan's last sections list them. Plan 42 follows it. -> **plan 42 is DRAFTED**. The Spec Studio's phase 0, a read-only static view (`sdp studio`), -> re-entered on the owner's rulings; intent in `pack:spec-studio-v1`. A design pass splits the -> Studio's Specs for honest binding and prototypes the Pack page before any code. +> **plan 42 is EXECUTED** as the Studio lane above. The Spec Studio's phase 0, a read-only +> static view (`sdp studio`); intent in `pack:spec-studio-v1`. Its design pass split the Studio's +> Specs for honest binding and prototyped the Pack page before the build. > **plan 41 is EXECUTED**. The design-management arc, intent in `pack:design-management-v1`: the > Design Review's Pack page shows a Pack's design (the Pack design page, MD-39), the graph > records entry lines, recipes take `--params` and ship under `dist/recipes/`, recipes 29 to 32 @@ -118,7 +124,7 @@ Progressive disclosure — start at the top, follow the pointers down. | `jtbd-stories/` | **the jobs (functional spec)** — stable `When / I want / so I can` stories (themes A–H); no personas, because consumers are heterogeneous (humans, CI, CLIs, **AI agents**) | to know *what* we serve | | `docs/concept/` (+ README) | **the technical design** — the surviving principle-led docs: vision & MVP boundary, founding principles (P1–P10), authoring & binding, the consumer pointers and discipline mapping (06), and the MVP acceptance of record (07); the core model, the one graph, validation & honesty and the consumer law dissolved into the `model.*`, `extraction.*`, `validation.*` and `consumers.*` Spec families; the historical MVP slice record lives in the slice-5 plan under `plans/` and in `examples/checkout-v1`, while forward roadmap intent lives in capture Specs — locate any of them with concept search (recipe 6) | to know *how* it is designed | | `docs/concept/DECISIONS.md` | **the lean decision registry** — ratified names, one-line glosses, carrying Specs, and the D1–D6 lookup; historical rationale lives in git, plans, and the Specs themselves | when resolving a decision name or following its canonical pointer | -| `src/` | **the engine** — `model` (Spec/descriptors/pack/anchors) · `extract` · `graph` · `validate` · `reader` (agent surface) · `projections` (Design Review, census, Mermaid and Gherkin) · `cli` (`sdp build` · `validate` · `view` · `census` · `mermaid` · `gherkin` · `import` · `new spec` · `q`) · `import` (carrier conversion and import analysis) · `testing` (the registrar runtime exported through `./testing`) · `runner` / `codegen` / `notation` / `adapters` | when implementing or verifying **current engine** behavior | +| `src/` | **the engine** — `model` (Spec/descriptors/pack/anchors) · `extract` · `graph` · `validate` · `reader` (agent surface) · `projections` (Design Review, census, Mermaid, Gherkin and Studio) · `cli` (`sdp build` · `validate` · `view` · `census` · `mermaid` · `gherkin` · `studio` · `import` · `new spec` · `q`) · `import` (carrier conversion and import analysis) · `testing` (the registrar runtime exported through `./testing`) · `runner` / `codegen` / `notation` / `adapters` | when implementing or verifying **current engine** behavior | | `examples/checkout-v1` | **the worked MVP example** (Markdown carriers; originally the TS DSL tracer bullet) — specs, anchors, untracked `generated/` (regenerated in-pipeline); walkthrough in its README | when proving the loop end-to-end | | `explorations/` | **evidence only** (carrier exhibits, executable-example findings) — mapping evidence for design; **never promote spike code into product** | when judging design evidence; not a source tree to ship | | `docs/lineage/` | restored historical design documents — the v0 concept set, verbatim with lineage headers; evidence for design reviews, never intended truth | when a review needs superseded design on the table rather than in git history | @@ -157,11 +163,14 @@ pnpm --silent sdp:q 'return g.specs().filter((s) => s.statedReadiness === "ready pnpm --silent sdp:q "$(cat dist/recipes/06-where-is-this-concept.js)" --params '{"term":"readiness floor"}' ``` -The full CLI surface is `sdp build · validate · view · census · mermaid · gherkin · import · new spec · q`. +The full CLI surface is `sdp build · validate · view · census · mermaid · gherkin · studio · import · new spec · q`. `sdp validate --watch` re-runs that same validate path on carrier create, change, delete, or rename and stays alive after findings; operator stop exits 0. `--watch` is validate-only and cannot combine -with `--check-clean`. The four projection publishers remain independent public verbs; repository -generation/check scripts certify all four roots through the private projection-suite driver. The +with `--check-clean`. `sdp validate --rules PATH [--params FORM]...` runs an operator-selected corpus +rule, with each `--params` after its own `--rules`, corpus findings and failed files reported apart +from Protocol findings, and rules reread on each watch cycle. The five projection publishers remain +independent public verbs; repository +generation/check scripts certify all five roots through the private projection-suite driver. The runnable recipe bodies live in `docs/agent-surface/recipes.md` (each executed as written by `test/recipes.test.ts`), and the repository-owned skills (`sdp-agent-surface` for reading, `sdp-authoring` for writing intent, `sdp-sessions` for advisory work-shape routing) are the @@ -309,8 +318,8 @@ to run. Development is terminal-driven. Node ≥ 20 (Node 22 is installed); depe the committed `package-lock.json` (npm is the lockfile-native package manager; `pnpm` scripts also work, but never a bare `sdp`). -The startup update script only refreshes dependencies (`npm ci`). It does **not** build, so on a -fresh pod you must run `npm run build` before the `sdp` CLI (`dist/cli/sdp.js`) exists. +`npm ci` builds the package through its `prepare` script. CI installs with `--ignore-scripts` +and builds in `npm run check`. `npm run check:git-install` checks installation from a commit. Non-obvious gotcha: `npm test` refuses to run until the generated contracts exist and errors with "Generated contracts required by the selected test suite are missing." Build and generate first: diff --git a/CONTEXT.md b/CONTEXT.md index b67c1d71..738f9c35 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -51,7 +51,7 @@ The familiar delivery nouns are **named coordinates on the one primitive, never | **Scenario** | a low-altitude `example`-kind `Spec` that `refines`/`verifies` a Story | an altitude or a kind | | **NFR** | a `constraint` with a measurable `target`; the *flavor* (performance / security / …) lives on the section | a separate kind | | **Executable Spec** | an `example` that **has a verifier** (a delivery fact) | a readiness rung | -| **capability / domain** | a **`Pack` grouping** and/or a designed-for Capability Map projection over high-altitude `behavior` Specs; no Capability Map is built, and its grouping is held open in `spec:consumers.spec-studio.lenses#question.capabilityGrouping` | a kind or an altitude | +| **capability / domain** | a **`Pack` grouping** and/or a designed-for Capability Map projection over high-altitude `behavior` Specs; no Capability Map is built, and the Studio groups Specs by Pack and draws no capability tree (`spec:consumers.spec-studio.lenses#rule.packGrouping`) | a kind or an altitude | ## Sections (→ `spec:model.spec-sections`) @@ -129,7 +129,7 @@ facts literal while the canonical recipe reports both audited exclusions. | **`validator`** | one individual deterministic check; groups into the two families below | — | | **conformance checks** | "is this *well-formed* against the meta-model?" — referential integrity · duplicate IDs · `claim` separation · grouping coherence · ambiguity-is-loud | — | | **honesty checks** | "is this *not pretending*?" — authoring-shape honesty (no hand-authored derived edges/facts) · honest readiness against the floor | — | -| **corpus rule** | an operator-selected graph body a corpus runs through its own `validate --rules` invocation, designed and not yet built; its findings name their corpus and sit outside the conformance and honesty families; neither a rule-kind Spec nor a Protocol validator (`spec:validation.corpus-rules`) | "corpus check" · "custom validator" | +| **corpus rule** | an operator-selected graph body a corpus runs through its own `validate --rules` invocation; its findings name their rule and rules file and sit outside the conformance and honesty families; neither a rule-kind Spec nor a Protocol validator (`spec:validation.corpus-rules`) | "corpus check" · "custom validator" | | **readiness floor** | the **minimum structural requirement to *state*** a readiness rung — a floor to clear, **never a quota to fill** or a score | "readiness profile" | | **`gap`** | a surfaced absence (e.g. `ready` with no verifier) — informative, never a gate | — | | **`orphan`** | a `Spec` with no relations and nothing pointing at it — informative | — | @@ -156,7 +156,7 @@ validated," never "provably correct."** | **evaluation sink** | the front door's CLI entrance, the verb `sdp q`: derives the graph in process on every invocation, injects `g` / `graph` / `report` / `params`, evaluates the operator-supplied async body, prints its `return`; writes nothing and mints no query vocabulary (`spec:consumers.agent-surface.evaluation-sink`) | a REPL · a second read model | | **demand map** | the ruled statement of how agents actually arrive — holding a **string**, a **file**, or a **changeset**, never the Spec id they are looking for — carried as clauses on `spec:consumers.agent-surface`; the reason the frozen entry adapters are `findByConcept` · `byFile` · `blastRadius` | — | | **recipe** | a runnable, documented `sdp q` body composing the frozen adapters — the growth valve past them: when a question is not answered, script it, and a join freezes into the `reader` only at the second-caller bar; catalog at `docs/agent-surface/recipes.md`, every body executed as written by the recipe check | a new query verb | -| **recipe parameter** | the input a caller passes a recipe as data through `sdp q --params`, read by the body from `params`, falling back to the catalog's sample when the catalog gives one (`spec:consumers.agent-surface.recipe-parameters`) | "argument" · "recipe flag" | +| **recipe parameter** | the input a caller passes a recipe as data through `--params`, after the body under `sdp q` or after its file's `--rules` under `sdp validate`, read by the body from `params`, falling back to the catalog's sample when the catalog gives one (`spec:consumers.agent-surface.recipe-parameters`) | "argument" · "recipe flag" | | **Pack design** | the reader's per-Pack assembly of each member's stated next rung, design columns and code bindings, with the Pack's boundary; the Design Review's Pack page renders it (`spec:consumers.pack-design`) | "Pack page" (names the rendering) · "capability page" | | **design columns** | the counts and lists derived for a Spec beside its readiness: its keyed Design and UI entries, pinned declarations, open and blocking questions, and the decisions it names; never a score or a rung; what design a Spec carries is read from these columns and never stated, while readiness stays the author's stated design maturity of the intent (`spec:model.design-maturity`) | "design score" · "designed" (the original design's stage) | | **boundary** | the Specs outside a Pack joined to a member by one of the four typed relations `refines`, `dependsOn`, `constrainedBy` and `decidedBy`: the Pack **rests on** a Spec a member names by one of them and is **rested on by** a Spec that names a member by one of them; outside Specs that `verifies` or `supersedes` a member list apart as `verifiedBy` and `supersededBy` (`spec:consumers.pack-design`) | "outside dependencies" · "perimeter" | @@ -258,9 +258,10 @@ delivery fact, workflow state, or graph state. revisit when `doc:`-target relations or pack-targeting arrive. - **"vault"** is Obsidian's word for a folder of linked notes, used by the `idea` captures in - `pack:obsidian-vault-v1` for a projection regenerated from the graph. Whether that projection - stands alone or is the Studio's local half is open (`spec:consumers.obsidian-vault`), and the - term waits on that ruling; a **term address** (`spec:model.term-addresses`) is a candidate too. + `pack:obsidian-vault-v1` for a projection regenerated from the graph. That projection stands + alone, beside the Studio and not as its local half (`spec:consumers.obsidian-vault`), and waits + for a reader, so the term stays unratified until it is built; a **term address** + (`spec:model.term-addresses`) is a candidate too. ## Term ledger (locked / rejected / resolved) diff --git a/README.md b/README.md index 629fdb35..e46091e4 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,19 @@ Read the [concept](docs/concept/README.md), the [ubiquitous language](CONTEXT.md ## Source-checkout quick start -Install from the committed lockfile and build the CLI: +Install from the committed lockfile. The `prepare` script builds the CLI: ```sh npm ci -npm run build ``` +`npm run check:git-install -- ` installs a committed revision from a local git URL with +npm and, when present, pnpm. It also extracts a git archive without `.git`, runs `prepare` through +`npm pack`, and installs that package with npm. Both routes check the commit in the CLI version, +export files and a shipped recipe. The archive route exercises the build used by hosted git +dependencies locally; it makes no request to GitHub. The check needs network access for +dependencies and tests only committed work, so CI runs it after `npm run check`. + Then query this repository's self-hosting graph with either supported script runner: ```sh @@ -53,6 +59,62 @@ disables pnpm 11's dependency auto-reconciliation for repository scripts, so the ## Installed CLI (adopter repositories) +Pin the Protocol to a full commit SHA in your adopter's `package.json`: + +```json +{ + "dependencies": { + "@libar-dev/software-delivery-protocol": "github:libar-dev/software-delivery-protocol#" + } +} +``` + +Replace `` with the commit you reviewed, then install and commit the lockfile. +The package's `prepare` script builds `dist/`, the CLI, subpath exports and shipped recipes during +the git install. While the version is `0.0.0`, use a commit pin; no npm prerelease is published. +See [npm's prepare lifecycle](https://docs.npmjs.com/cli/v11/using-npm/scripts/). + +Both package managers approve this build by its git source, never by package name alone. + +With npm, approve the repository in your adopter's `package.json` and allow root git +dependencies in its `.npmrc`. npm 11.16 and later warn without the approval; npm 12 refuses git +dependencies and skips unapproved scripts by default. + +```json +{ "allowScripts": { "github:libar-dev/software-delivery-protocol": true } } +``` + +```ini +allow-git=root +``` + +Then run `npm install`. + +With pnpm 11.19 or later, approve the repository once in `pnpm-workspace.yaml`. The approval +covers every commit you pin: + +```yaml +allowBuilds: + "@libar-dev/software-delivery-protocol@git+https://github.com/libar-dev/software-delivery-protocol.git": true +``` + +On pnpm 10 and earlier pnpm 11 releases, approve the exact source and commit instead: copy the +key that `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED` prints under `onlyBuiltDependencies` (pnpm 10) +or `allowBuilds` (pnpm 11), and replace it at each re-pin. Keep this alongside your existing +build permissions, then run `pnpm install`. See npm's +[`approve-scripts`](https://docs.npmjs.com/cli/v11/commands/npm-approve-scripts) and pnpm's +[source-qualified permissions](https://pnpm.io/settings/build#allowbuilds). + +Verify either install through the linked binary: + +```sh +./node_modules/.bin/sdp --version +``` + +The output is `sdp 0.0.0 ()`, with the full commit you pinned. The build reads it from git in +a clone or from the substituted commit file in a hosted git archive. It reports `unknown` when +neither source supplies a full commit. + In an adopter repository where the Protocol is installed as a dependency, its binary **is** linked into `node_modules/.bin`, so the package runner resolves it: @@ -61,16 +123,39 @@ pnpm exec sdp --help pnpm exec sdp build . pnpm exec sdp validate . pnpm exec sdp view . +pnpm exec sdp studio . +pnpm exec sdp validate . --rules design/policy.js --params @design/policy.json pnpm exec sdp q 'return g.specs().map((spec) => spec.id)' ``` `build` derives the graph, executable contracts and registrars, `validate` adds conformance and honesty checks, `view` generates the Design Review, `census` the census, `mermaid` the Mermaid -diagrams, and `gherkin` the Gherkin-shaped read view. `new spec` scaffolds an idea-rung Markdown -carrier, `import` converts TypeScript Spec carriers to Markdown, and `q` evaluates a local +diagrams, `gherkin` the Gherkin-shaped read view, and `studio` the static Spec Studio pages. +`new spec` scaffolds an idea-rung Markdown carrier, `import` converts TypeScript Spec carriers to Markdown, and `q` evaluates a local JavaScript query body against a freshly derived graph. Run `sdp --help` for the complete option contract. Adopters own their root and exclusion policy. +`validate --rules PATH [--params FORM]...` runs a local async graph body returning `{ findings }`. +Repeat `--rules` for more files and put each file's parameters after that flag. Corpus findings +and failed files print apart from Protocol findings; a corpus error or failed file exits 1. +With `--watch`, carrier changes reread the rules files; editing a rules file alone starts no cycle. +The five projection publishers each replace their own root after the build clears the previous +roots. The repository's generation and check scripts publish all five together. + +### Upgrade recipe callers + +Recipe 25, address resolution, now reads `params.addresses`. A script that replaces the recipe's +sample address array with a regular expression can silently stop matching. Remove that rewrite +and pass the subject as data to the shipped recipe: + +```sh +./node_modules/.bin/sdp q "$(cat node_modules/@libar-dev/software-delivery-protocol/dist/recipes/25-address-resolution.js)" --params '{"addresses":["spec:orders.create-order#rule.validCart"]}' --json +``` + +Use your corpus's addresses, or pass `--params @addresses.json` with an object containing +`addresses`. Other parameterized recipes follow the same pattern. The +[recipe catalog](docs/agent-surface/recipes.md) names each parameter. + ## Gherkin carrier Behavior and example Specs may use a `.sdp.gherkin` file as their one canonical surface. diff --git a/check-carrier-rule.mjs b/check-carrier-rule.mjs deleted file mode 100644 index 60b8b1cd..00000000 --- a/check-carrier-rule.mjs +++ /dev/null @@ -1,115 +0,0 @@ -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -// check-carrier-rule — the structured consistency gate for the canonical-default carrier rule -// (the carrier ruling, MD-18). -// -// Asserts, and NAMES each disagreeing surface on failure: -// 1. the SAME post-flip rule sentence in the three current-rule surfaces (the decision diary, -// the glossary, the agent handbook): Specs default to Markdown; Packs remain TS until a -// Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option; -// 2. the obsolete sole-canonical-TS wording gone from the diary and the glossary, and the -// surviving law (one canonical surface per ID, no mixing) still present in the diary; -// 3. the extension-law re-point (the `.sdp.ts` extension law, MD-15) present in the diary; -// 4. the logical/physical relations distinction present on BOTH surviving surfaces, each in its -// own words — the carrying Spec (the Markdown envelope contract) and JS-A1: relations stay -// OPTIONAL in the logical Spec; the physical Markdown envelope writes `relations: {}` when the -// logical set is empty — honest carrier syntax, never a new logical relation requirement. The -// concept doc that used to hold the JS-A1-verbatim twin dissolved into its carrying Specs, so -// this leg proves co-presence of the law, not verbatim sameness across the two surfaces: -// JS-A1 keeps the verbatim sentence, the Spec states the same law as it authored it. -// -// Usage: node check-carrier-rule.mjs [rootDir] -// rootDir — tree holding the operative surfaces; defaults to this repo root. QA passes a -// temp tree of mutated copies to prove disagreeing surfaces are named. - -const rootDir = process.argv[2] ?? dirname(fileURLToPath(import.meta.url)); - -const SURFACES = { - decisions: "docs/concept/DECISIONS.md", - glossary: "CONTEXT.md", - agents: "AGENTS.md", - envelopeContract: "specs/carrier/envelope-contract.sdp.md", - jsA1: "jtbd-stories/01-capture-and-evolve-intent.md", -}; - -// The canonical-default rule. Verbatim (modulo whitespace) in the diary, the glossary, and the -// agent handbook — never paraphrased per surface. The trailing period is surface-local -// punctuation (the AGENTS status row continues with its `·` separator), so it is not asserted. -const CARRIER_RULE = - "Specs default to Markdown; Packs remain TS until a Pack syntax ruling; the TS DSL survives as import source and a lawful per-ID option"; - -// The owner-confirmed logical/physical relations distinction. Verbatim in JS-A1. -const RELATIONS_DISTINCTION = - "Relations are optional in the logical `Spec` model. A physical Markdown envelope writes `relations: {}` when the logical set is empty: honest carrier syntax, not a new logical relation requirement."; - -// The same law in the carrying Spec's own authored words. -const ENVELOPE_RELATIONS_CLAUSE = - "`relations: {}` is written explicitly when the logical relation set is empty — honest carrier syntax, not a new logical requirement: the physical key catches a truncated envelope at reification while the model itself stays relation-optional."; - -const SURVIVING_LAW = "one canonical surface per ID, no mixing"; -const OBSOLETE_SOLE_CANONICAL = "stays sole-canonical"; -const EXTENSION_LAW_REPAIR = "re-pointed, not repealed"; - -// Sameness is about WORDS, so markdown quoting furniture is stripped before whitespace -// normalization: a sentence wrapped inside a `>` blockquote (the AGENTS status row) must read -// identically to its plain-paragraph twins. -const norm = (text) => text.replace(/^>\s?/gm, "").replace(/\s+/g, " "); -const read = (rel) => norm(readFileSync(join(rootDir, rel), "utf8")); - -const failures = []; -const expectContains = (surface, needle, why) => { - if (!read(surface).includes(norm(needle))) { - failures.push(`${surface} — ${why}`); - } -}; -const expectOmits = (surface, needle, why) => { - if (read(surface).includes(norm(needle))) { - failures.push(`${surface} — ${why}`); - } -}; - -for (const surface of [SURFACES.decisions, SURFACES.glossary, SURFACES.agents]) { - expectContains(surface, CARRIER_RULE, "missing the canonical-default carrier-rule sentence"); -} -expectContains( - SURFACES.decisions, - SURVIVING_LAW, - "dropped the surviving one-canonical-surface law", -); -expectOmits( - SURFACES.decisions, - OBSOLETE_SOLE_CANONICAL, - "still carries the obsolete sole-canonical-TS wording", -); -expectOmits( - SURFACES.glossary, - OBSOLETE_SOLE_CANONICAL, - "still carries the obsolete sole-canonical-TS wording", -); -expectContains( - SURFACES.decisions, - EXTENSION_LAW_REPAIR, - "missing the extension-law re-point (the `.sdp.ts` extension law, MD-15)", -); -expectContains( - SURFACES.envelopeContract, - ENVELOPE_RELATIONS_CLAUSE, - "missing the logical/physical relations distinction", -); -expectContains( - SURFACES.jsA1, - RELATIONS_DISTINCTION, - "missing the logical/physical relations distinction", -); -if (failures.length > 0) { - console.error("check-carrier-rule — disagreeing surfaces:\n"); - console.error(failures.join("\n")); - process.exit(1); -} - -console.log( - "check-carrier-rule — all surfaces agree: canonical-default carrier rule and relations distinction.", -); -process.exit(0); diff --git a/check-git-install.mjs b/check-git-install.mjs new file mode 100644 index 00000000..bb1fbf29 --- /dev/null +++ b/check-git-install.mjs @@ -0,0 +1,122 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import assert from "node:assert/strict"; + +// A committed-source install needs network access and dependency builds. Keep it outside the +// pooled unit suite and run it explicitly, or as the final CI step, against the selected commit. +const root = dirname(fileURLToPath(import.meta.url)); +const packageName = "@libar-dev/software-delivery-protocol"; +const revision = process.argv[2] ?? "HEAD"; +const parent = resolve(process.argv[3] ?? tmpdir()); +mkdirSync(parent, { recursive: true }); +const scratch = mkdtempSync(join(parent, "sdp-git-install-")); +const run = (command, args, cwd = root) => + execFileSync(command, args, { + cwd, + encoding: "utf8", + timeout: 300_000, + maxBuffer: 8 * 1024 * 1024, + }); +const commit = run("git", ["rev-parse", "--verify", `${revision}^{commit}`]).trim(); +const cloneDependency = `git+${pathToFileURL(root).href}#${commit}`; + +try { + const managers = ["npm"]; + const pnpm = spawnSync("pnpm", ["--version"], { encoding: "utf8" }); + if (pnpm.status === 0) managers.push("pnpm"); + else console.log("pnpm unavailable; npm install only"); + // Build the archived source exactly where no enclosing checkout can supply a commit. + // npm pack runs prepare; a direct tarball install alone would not run this build. + const archiveRoot = join(scratch, "archive-source"); + const archive = join(scratch, "source.tar"); + mkdirSync(archiveRoot); + run("git", ["archive", "--format=tar", "--output", archive, commit]); + run("tar", ["-xf", archive, "-C", archiveRoot]); + assert.ok(!existsSync(join(archiveRoot, ".git"))); + run("npm", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], archiveRoot); + const packed = JSON.parse( + run("npm", ["pack", "--json", "--pack-destination", scratch], archiveRoot), + ); + assert.equal( + JSON.parse(readFileSync(join(archiveRoot, "dist/cli/build-info.json"), "utf8")).commit, + commit, + ); + const routes = [ + ...managers.map((manager) => ({ manager, route: "clone", dependency: cloneDependency })), + { manager: "npm", route: "archive", dependency: `file:${join(scratch, packed[0].filename)}` }, + ]; + for (const { manager, route, dependency } of routes) { + const consumer = join(scratch, `${manager}-${route}`); + mkdirSync(consumer); + writeFileSync( + join(consumer, "package.json"), + JSON.stringify({ + name: "sdp-git-consumer", + private: true, + type: "module", + dependencies: { [packageName]: dependency }, + ...(manager === "npm" + ? { allowScripts: { [`git+${pathToFileURL(root).href}`]: true } } + : {}), + }), + ); + if (manager === "npm") { + writeFileSync(join(consumer, ".npmrc"), "allow-git=root\n"); + } else { + const major = Number(pnpm.stdout.trim().split(".")[0]); + writeFileSync( + join(consumer, "pnpm-workspace.yaml"), + major >= 11 + ? `allowBuilds:\n ${JSON.stringify(`${packageName}@${dependency}`)}: true\n` + : `onlyBuiltDependencies:\n - ${JSON.stringify(`${packageName}@${dependency}`)}\n`, + ); + } + const started = Date.now(); + run( + manager, + [ + "install", + ...(manager === "npm" + ? ["--no-audit", "--no-fund"] + : ["--reporter=append-only", "--store-dir", join(scratch, "pnpm-store")]), + ], + consumer, + ); + const installed = join(consumer, "node_modules", ...packageName.split("/")); + const cli = join(consumer, "node_modules", ".bin", "sdp"); + 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); + } + const recipe = readFileSync(join(installed, "dist/recipes/25-address-resolution.js"), "utf8"); + writeFileSync( + join(consumer, "own.sdp.md"), + `---\nid: spec:probe.installed\nkind: behavior\naltitude: story\nreadiness: idea\nrelations: {}\n---\n# Installed corpus\n\n## Intent\n- outcome: Read an installed recipe.\n\n## Behavior\n- rule: [#installed] Resolve this rule.\n`, + ); + const result = JSON.parse( + run( + cli, + [ + "q", + recipe, + "--params", + '{"addresses":["spec:probe.installed#rule.installed"]}', + "--json", + ], + consumer, + ), + ); + assert.equal(result.totals.addresses, 1); + assert.equal(result.totals.resolved, 1); + console.log( + `${manager} ${route}: ${version}; exports and shipped recipe work; ${((Date.now() - started) / 1000).toFixed(1)}s; commit ${commit}`, + ); + } +} finally { + rmSync(scratch, { recursive: true, force: true }); +} diff --git a/contract-dependent-suites.mjs b/contract-dependent-suites.mjs index 96604c6c..e2a47241 100644 --- a/contract-dependent-suites.mjs +++ b/contract-dependent-suites.mjs @@ -24,6 +24,17 @@ export const contractDependentSuites = [ contracts: "generated/contracts", generation: "npm run generate:self-hosting", testPaths: [ + "test/self-hosting-corpus-checks.test.ts", + "test/validation.corpus-rules.copies-contain-changes.test.generated.ts", + "test/validation.corpus-rules.error-finding-fails.test.generated.ts", + "test/validation.corpus-rules.malformed-finding-fails.test.generated.ts", + "test/validation.corpus-rules.warning-finding-informs.test.generated.ts", + "test/validation.architecture-constraints.forbidden-layer-pair.test.generated.ts", + "test/validation.architecture-constraints.isolated-context-as-rule.test.generated.ts", + "test/validation.architecture-constraints.unstated-layer-unclassified.test.generated.ts", + "test/model.planned-architecture.differing-layer-and-uses.test.generated.ts", + "test/model.planned-architecture.matching-design.test.generated.ts", + "test/model.planned-architecture.unrealized-design.test.generated.ts", "test/self-hosting-next-rung-floor.test.ts", "test/validation.next-rung-floor.blocking-question.test.generated.ts", "test/validation.next-rung-floor.typed-dependency-targets.test.generated.ts", diff --git a/docs/agent-surface/recipes.md b/docs/agent-surface/recipes.md index be06d833..bf1ade06 100644 --- a/docs/agent-surface/recipes.md +++ b/docs/agent-surface/recipes.md @@ -37,11 +37,14 @@ | 33 | The design backlog | none | Pick defined design work | | 34 | Rule coverage | spec or pack | Find keyed rules no example exercises | | 35 | The design diff | base, pack, detail | Compare design at entry grain | -| 36 | The context bundle | pack or specs, members, budget | Compose one pass within a budget | +| 36 | The context bundle | pack or specs, members, budget, declarations | Compose one pass within a budget | | 37 | The perspective filter | pack, perspective | Select and order Pack members | | 38 | The proposal check | proposal, base | Resolve proposal scopes and compare intent | | 39 | Slimming candidates | pack | Compare pinned declarations with bound code | | 40 | Curation report | imports, limit | Find curation gaps from file imports | +| 41 | The key-family register | prefixes, pack or specs | List a key family's entries | +| 42 | The architecture walk | forbidden, isolated, severity | Check anchored architecture rules | +| 43 | Planned against realized components | none | Compare component designs with anchors | Runnable bodies for the agent front door. Each recipe below is a body you can pass verbatim to `sdp q`, unchanged. In the Protocol source checkout, pass one to the repository wrapper: @@ -61,12 +64,12 @@ the recipe check derives with. Run `npm run build` first if `dist/` is absent. D link itself into its own `node_modules/.bin`; an unresolved `sdp` can select macOS's unrelated binary. -The same CLI publishes four independent read roots with `view`, `census`, `mermaid`, and `gherkin`. -Each publisher's build removes all four projection roots first. -The repository's generate/check scripts preserve and publish all four roots together. They save +The same CLI publishes five independent read roots with `view`, `census`, `mermaid`, `gherkin`, and `studio`. +Each publisher's build removes all five projection roots first. +The repository's generate/check scripts preserve and publish all five roots together. They save the prior trees before the individual publishers run, preserve each result across the next build's -invalidation, and restore all four results at the end. In check mode a prior tree must exist for -each root; Design Review checks repeat-render determinism, while census, Mermaid and Gherkin also +invalidation, and restore all five results at the end. In check mode a prior tree must exist for +each root; Design Review checks repeat-render determinism, while census, Mermaid, Gherkin and Studio also compare the prior tree's bytes with the current render. Graph questions enter through `q`. For adopters, the portable form keeps root and exclusions project-selected: @@ -81,8 +84,9 @@ repeatable for root-relative path prefixes. `PATH` is a placeholder, not a liter Recipes read their documented input from `params`: Spec or component ids, changed files, Pack ids, search terms, Spec or address lists, base graphs, proposals, perspectives, imports objects, -budgets and detail selections. Each recipe names its parameter. Pass it as JSON data with -`--params`, never by editing the body: +budgets and detail selections. Each recipe declares the parameter names it takes before reading its +input; a name it does not take returns only `{ unknownParams, accepts }`, with exit 0. +Pass parameters as JSON data with `--params`, never by editing the body: ```sh pnpm --silent sdp:q '' --params '{"spec":"spec:model.anchors"}' --json @@ -99,7 +103,7 @@ 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 and perspective filter report an absent +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 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. @@ -118,10 +122,12 @@ pnpm --silent sdp:q "$(cat dist/recipes/25-address-resolution.js)" --params @add This catalog stays the one owner of the bodies. The files are derived from it, and the recipe check holds each one byte for byte to its body here. -To filter a shipped answer, wrap its body in an async arrow function and return the part you need: +To filter a shipped answer, wrap its body in an async arrow function and return the part you need. +Give the inner body only its own parameters, so an outer caller's parameters do not become unknown +names in the recipe: ```sh -pnpm --silent sdp:q "$(printf '%s\n' 'const answer = await (async () => {'; cat dist/recipes/33-the-design-backlog.js; printf '%s\n' '})();' 'return answer.rows.filter((row) => row.specKind === "behavior");')" --json +pnpm --silent sdp:q "$(printf '%s\n' 'const answer = await (async (params) => {'; cat dist/recipes/33-the-design-backlog.js; printf '%s\n' '})({});' 'return answer.rows.filter((row) => row.specKind === "behavior");')" --json ``` The caller's part must hold no shell expansion. Single-quote it as above so the shell inserts only @@ -177,6 +183,9 @@ for a new query verb. A join freezes into the reader only when a second machine example evidence and decision records — while keeping both exclusions visible in the result.* ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const ready = g.specs().filter((spec) => spec.statedReadiness === "ready"); const backlog = ready.filter( (spec) => @@ -239,6 +248,9 @@ These columns are derived, never a score, rung or approval. finished, `implemented ∧ ¬ready`.* ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const rungs = ["idea", "scoped", "defined", "ready"]; const alarms = g .specs() @@ -273,6 +285,9 @@ The parameter is `params.spec`, the Spec id to read; an unknown id returns `{ id rather than failing. ```js +const accepts = ["spec"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const id = params.spec ?? "spec:consumers.reader"; const context = g.specContext(id); @@ -361,6 +376,9 @@ filenames. `--params` parses data, so quotes, shell metacharacters, spaces, Unic newlines remain filenames rather than JavaScript or shell syntax. The reader never shells to git. ```js +const accepts = ["files", "detail"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const detail = params.detail ?? false; const changed = params.files ?? []; const radius = g.blastRadius(changed); @@ -439,6 +457,9 @@ The parameter is `params.pack`, the Pack to review; without it the body reads th the graph. An unknown Pack returns `{ id, found: false }`. ```js +const accepts = ["pack"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const id = params.pack ?? g.packs()[0]?.id; if (id === undefined) { @@ -486,6 +507,9 @@ aggregate over Specs that do. The parameter is `params.term`, the phrase to look for. ```js +const accepts = ["term"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const term = params.term ?? "blast radius"; const matches = g.findByConcept(term); @@ -510,6 +534,9 @@ deterministic substring, never fuzzy-scored: the same query returns the same row *When you need this: you want the Specs stating a rung their structure does not earn.* ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const rungs = ["idea", "scoped", "defined", "ready"]; const rank = (rung) => (rung === undefined ? -1 : rungs.indexOf(rung)); @@ -541,6 +568,9 @@ the stated rung — is information, not a problem: a floor is a floor, never a q *When you need this: you want the warn-level signals as data — informative absences, not a gate.* ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const warnings = report.findings.filter((finding) => finding.severity === "warning"); const byValidator = {}; @@ -581,6 +611,9 @@ refusals; a green corpus has no errors, but may carry intentional warnings. evidence before touching the carrier.* ```js +const accepts = ["spec"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const id = params.spec ?? "spec:model.enrichment-lifecycle"; const context = g.specContext(id); @@ -630,6 +663,9 @@ A typed-dependency failure lists its `targets`; neither answer confers a rung. *When you need this: you want example intent and graph-visible verifier realization kept distinct.* ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const rows = []; for (const spec of g.specs()) { @@ -666,6 +702,9 @@ contract no suite binds, and it never reports runner pass or fail. and the first clause that holds it below that rung.* ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const lower = g.specs().filter((spec) => spec.statedReadiness !== "ready"); const byFamily = Object.create(null); @@ -693,6 +732,9 @@ blocks that rung. The author still decides whether to state it; the recipe never *When you need this: you want to know which code units belong to each declared component.* ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const componentIds = new Set( graph.nodes .filter((node) => node.nodeType === "CodeNode" && node.id.startsWith("component:")) @@ -722,6 +764,9 @@ and the component ids come from the graph's declared component nodes. *When you need this: you want to know which components depend on which, with both directions visible.* ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const componentIds = new Set( graph.nodes .filter((node) => node.nodeType === "CodeNode" && node.id.startsWith("component:")) @@ -772,6 +817,9 @@ The parameter is `params.component`, the component you are reviewing; an unknown returns the exact not-found shape and does not throw. ```js +const accepts = ["component"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const subject = params.component ?? "component:protocol.reader"; const component = graph.nodes.find((node) => node.nodeType === "CodeNode" && node.id === subject); @@ -811,6 +859,9 @@ This reads validation findings already supplied by `report`; it never revalidate dangling references. ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const components = graph.nodes .filter((node) => node.nodeType === "CodeNode" && node.id.startsWith("component:")) .map((node) => node.id) @@ -849,6 +900,9 @@ keeps census's validator-owned honesty signal distinct from structural data. *When you need this: you want the graph-side slice each shipped projection root is allowed to render.* ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const specs = graph.nodes.filter((node) => node.nodeType === "Primitive"); const packs = graph.nodes.filter((node) => node.nodeType === "Pack"); const anchors = graph.nodes.filter( @@ -874,6 +928,9 @@ refusal or inclusion can therefore make rendered output smaller than these count satisfied Specs, and shaping decisions in one map.* ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const nodes = graph.nodes; const edges = graph.edges; const codeNodesById = new Map( @@ -1025,6 +1082,9 @@ For worked examples and the gen-1 design comparison, see [Architecture through t (dependsOn, refines, supersedes) and the Specs it decides, ranked by how much it shapes.* ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const decisionNodes = graph.nodes .filter( (node) => @@ -1126,6 +1186,9 @@ The parameter is `params.spec`, the Spec you are planning around; an unknown id `{ id, found: false }` rather than failing. ```js +const accepts = ["spec"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const id = params.spec ?? "spec:consumers.agent-surface"; const context = g.specContext(id); @@ -1302,6 +1365,9 @@ Dependency neighbors carry `statedReadiness` because an unready first, without keeping that table by hand.* ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const rows = []; const malformed = []; @@ -1412,6 +1478,9 @@ The parameter is `params.spec`, the Spec you are weighing; an unknown id returns `{ id, found: false }` rather than failing. ```js +const accepts = ["spec"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const id = params.spec ?? "spec:extraction.derive-graph"; const context = g.specContext(id); @@ -1485,6 +1554,9 @@ The parameter is `params.scope`, a list of Spec ids to audit mentions from those empty or absent list audits the whole corpus. ```js +const accepts = ["scope", "intended"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const scope = params.scope ?? []; const intendedRows = params.intended ?? []; const backingTypes = [ @@ -1691,6 +1763,9 @@ search (recipe 6) stops at the section.* The parameter is `params.term`, your word or phrase; an empty term returns no matches. ```js +const accepts = ["term"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const term = params.term ?? "suffix"; const tokensOf = (text) => text @@ -1893,6 +1968,9 @@ skipped. Keys carry the adopter's own role prefixes. The Protocol fixes no vocab rows by prefix in your own corpus if you need to. ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const rows = []; for (const spec of g.specs()) { const design = g.specContext(spec.id)?.sections?.design; @@ -1915,6 +1993,9 @@ The parameter is `params.addresses`, the list of addresses you hold; an empty li rows. A long list belongs in a file passed as `--params @PATH`. ```js +const accepts = ["addresses"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const addresses = params.addresses ?? [ "spec:consumers.design-review#ui.packPage", "spec:consumers.design-review#ui.memberTable", @@ -1986,6 +2067,9 @@ Specs, and this recipe resolves ones written anywhere else. through `dependsOn`.* ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const ids = g.specs().map((spec) => spec.id); const known = new Set(ids); const targets = new Map(ids.map((id) => [id, []])); @@ -2103,6 +2187,9 @@ The parameter is `params.pack`, the Pack you are reviewing; an unknown Pack retu `{ id, found: false }`. ```js +const accepts = ["pack"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const id = params.pack ?? "pack:spec-studio-v1"; const pack = g.packContext(id); @@ -2177,6 +2264,9 @@ role, layer, and context value with the units that carry it, before you name a n reconcile two spellings of one.* ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const units = graph.nodes.filter((node) => node.nodeType === "CodeNode"); const taxonomy = (field) => { const byValue = new Map(); @@ -2229,6 +2319,9 @@ for summaries, true for full rows, or a list of member ids for those full rows. `{ id, found: false }`. ```js +const accepts = ["pack", "detail"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const detail = params.detail ?? false; const id = params.pack ?? "pack:spec-studio-v1"; const pack = g.packContext(id); @@ -2407,6 +2500,9 @@ The parameter is `params.specs`, the Spec ids you are changing; an id the graph back in `changed` with `resolved: false` and reaches nothing. ```js +const accepts = ["specs"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const changed = params.specs ?? ["spec:validation.readiness-floor"]; const followed = new Set(["refines", "dependsOn", "constrainedBy", "decidedBy"]); const specIds = new Set(g.specs().map((spec) => spec.id)); @@ -2499,6 +2595,9 @@ ones, and `totals.units` the distinct units across all rows. The law is it, which decisions it refines and which refine it, what it shapes, and what it leaves open, read from the graph instead of kept by hand.* ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const byCodeUnit = (left, right) => (left < right ? -1 : left > right ? 1 : 0); const ends = (relations, type) => relations @@ -2574,6 +2673,9 @@ decisions by shaping fan-in. The law is `spec:consumers.agent-surface.design-rec layer, and which way it runs, before you write or review an architecture rule.* ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const byCodeUnit = (left, right) => (left < right ? -1 : left > right ? 1 : 0); const nodes = new Map(graph.nodes.map((node) => [node.id, node])); const componentOf = new Map(); @@ -2680,6 +2782,9 @@ references, before maturing the next design.* The body takes no parameter. ```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const defined = g.specs().filter((spec) => spec.statedReadiness === "defined" && !spec.deliveryFacts.includes("implemented")); const backlog = defined.filter((spec) => spec.specKind !== "example" && spec.specKind !== "decision"); const byFamily = Object.create(null); @@ -2727,6 +2832,9 @@ code-unit order. The law is `spec:consumers.agent-surface.answer-shapes`. Pass one scope, `params.spec` or `params.pack`. Without one the recipe asks for a scope. ```js +const accepts = ["spec", "pack"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; if (params.spec === undefined && params.pack === undefined) { return {found: false, reason: "scope", expected: "params.spec or params.pack"}; } @@ -2795,6 +2903,9 @@ its own exclusions. A base written by another engine is accepted and reports wha The caller keeps the base commit outside the graph. The recipe reads only the two graph objects. ```js +const accepts = ["base", "pack", "detail"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const base = params.base; const packId = params.pack; const detail = params.detail ?? false; @@ -3004,18 +3115,25 @@ with a budget and an account of what was left out.* Supply exactly one scope: `params.pack`, a Pack id, or `params.specs`, an ordered list of Spec ids. `params.members` optionally narrows a Pack to an ordered subset, such as the member ids recipe 37 returns. The Pack's framing and full boundary stay visible. `params.budget` defaults to 100,000 -UTF-16 code units and accepts a non-negative integer, including zero. There is no sample scope. +UTF-16 code units and accepts a non-negative integer, including zero. `params.declarations` defaults +to `"whole"`; `"addresses"` allows the final cut to reduce pinned declarations to entry addresses. +Any other value returns diagnostics. There is no sample scope. ```js +const accepts = ["pack", "specs", "members", "budget", "declarations"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const packId = params.pack; const specIds = params.specs; const subset = params.members; const budget = params.budget ?? 100000; +const declarationMode = params.declarations ?? "whole"; const diagnostics = []; const bad = (scope, problem) => diagnostics.push({ scope, problem }); const strings = (value) => Array.isArray(value) && value.every((item) => typeof item === "string"); if ((packId !== undefined) === (specIds !== undefined)) bad("scope", "Supply exactly one of pack or specs."); if (!Number.isInteger(budget) || budget < 0) bad("budget", "Expected a non-negative integer in UTF-16 code units."); +if (params.declarations === null || (declarationMode !== "whole" && declarationMode !== "addresses")) bad("declarations", "Expected \"whole\" or \"addresses\"."); let pack = null, ids = []; if (packId !== undefined) { pack = typeof packId === "string" ? g.packContext(packId) : undefined; @@ -3072,26 +3190,28 @@ const unitOf = (unit) => ({ id: unit.codeId, claim: unit.claim, file: unit.file const members = ids.map((id) => { const context = g.specContext(id); const sections = JSON.parse(JSON.stringify(context.sections ?? {})); + // One copy: open questions live in `questions`, never also in sections.intent. + if (sections.intent) delete sections.intent.openQuestions; const questions = context.designColumns.openQuestions.map((question) => ({ ...question, - address: question.key === undefined ? null : `${id}#question.${question.key}`, file: context.file, line: question.line ?? null })); + address: question.key === undefined ? null : `${id}#question.${question.key}`, line: question.line ?? null })); const footing = context.relationsOut.filter((end) => types.includes(end.type)).map((end) => { const target = summaries.get(end.otherId); return { type: end.type, id: end.otherId, claim: end.claim, resolved: end.resolved, ...(end.reason === undefined ? {} : { reason: end.reason }), statedReadiness: target?.statedReadiness ?? null, floorReached: target === undefined ? null : target.derivedReadiness ?? "none" }; }).sort((a, b) => types.indexOf(a.type) - types.indexOf(b.type) || byCodeUnit(a.id, b.id)); - const declarations = Object.entries(sections.design ?? {}).flatMap(([key, value]) => { - const declaration = key === "description" ? null : declarationOf(value); - return declaration === null ? [] : [{ spec: id, key, declaration }]; - }); + // One copy: each pinned value stays in sections.design; `declarations` names its key. + const declarations = Object.entries(sections.design ?? {}).flatMap(([key, value]) => + key !== "description" && declarationOf(value) !== null ? [key] : []); const exampleIds = [...new Set(context.relationsIn.filter((end) => ["refines", "verifies"].includes(end.type) && summaries.get(end.otherId)?.specKind === "example").map((end) => end.otherId))].sort(); const examples = exampleIds.map((exampleId) => { const example = g.specContext(exampleId); - return { id: exampleId, statedReadiness: example.statedReadiness, enabled: example.verifiers.some((verifier) => verifier.via === "test-anchor" && verifier.enabled), - file: example.file, steps: JSON.parse(JSON.stringify(example.sections?.behavior?.examples ?? [])) }; + const member = ids.includes(exampleId); + return { id: exampleId, member, file: example.file, statedReadiness: example.statedReadiness, enabled: example.verifiers.some((verifier) => verifier.via === "test-anchor" && verifier.enabled), + steps: member ? null : JSON.parse(JSON.stringify(example.sections?.behavior?.examples ?? [])) }; }); - return { id, title: context.title ?? null, specKind: context.specKind, altitude: context.altitude, + return { id, file: context.file, title: context.title ?? null, specKind: context.specKind, altitude: context.altitude, statedReadiness: context.statedReadiness, floorReached: context.derivedReadiness ?? "none", statedNextRung: context.statedNextRung ?? null, holds: context.statedNextRungFailures, narrative: context.narrative ?? null, sections, questions, footing, declarations, examples, design: { entries: context.designColumns.entries, declarations: context.designColumns.declarations, @@ -3100,7 +3220,7 @@ const members = ids.map((id) => { verifiers: context.verifiers.map((verifier) => ({ id: verifier.verifierId, claim: verifier.claim, enabled: verifier.enabled, file: verifier.file ?? null, line: verifier.line ?? null })) }; }); -const answer = { scope, budget, used: 0, tokenEstimate: { value: 0, method: "UTF-16 code units divided by four, an estimate" }, leftOut: [], +const answer = { scope, budget, declarations: declarationMode, used: 0, tokenEstimate: { value: 0, method: "UTF-16 code units divided by four, an estimate" }, leftOut: [], pack: pack ? { id: packId, title: pack.title ?? null, framing: pack.framing ?? null } : null, members, boundary }; const measure = () => { for (;;) { @@ -3111,17 +3231,20 @@ const measure = () => { answer.tokenEstimate.value = estimate; } }; +// The enclosing member names the Spec and file; the entry position names its path. const location = (id, path) => { const context = g.specContext(id); const question = /^intent\.openQuestions\[(\d+)\]/u.exec(path); const rule = /^behavior\.(rules|flows)\[(\d+)\]/u.exec(path); const entry = question ? `question[${question[1]}]` : rule ? `${rule[1] === "rules" ? "rule" : "flow"}[${rule[2]}]` : path; const at = context.entryLocations.find((row) => row.entry === entry); - return { spec: id, path, file: at?.file ?? context.file, line: at?.line ?? null }; + return { line: at?.line ?? null }; }; +const isPointer = (entry) => entry && typeof entry === "object" && !Array.isArray(entry) && + (entry.line === null || typeof entry.line === "number") && + Object.keys(entry).length === 1 && Object.hasOwn(entry, "line"); const address = (id, section, key) => key !== "description" && /^[a-z][A-Za-z0-9]*$/u.test(key) ? `${id}#${section}.${key}` : null; -// Entry addresses remain reading pointers even when the text containing them is cut. -const addressesIn = (value) => [...new Set((JSON.stringify(value) ?? "").match(/spec:[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)*#(?:design|ui|question|rule)\.[a-z][A-Za-z0-9]*(?![A-Za-z0-9_.#-])/gu) ?? [])]; +const addressesIn = (value) => [...new Set((JSON.stringify(value) ?? "").match(/spec:[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)*#(?:design|ui|question|rule)\.[a-z][A-Za-z0-9]*(?![A-Za-z0-9_#-]|\.[A-Za-z0-9])/gu) ?? [])]; const reference = (id, path, value, own = null) => { const addresses = [...new Set([...(own === null ? [] : [own]), ...addressesIn(value)])]; if (own !== null) return addresses.length === 1 ? own : addresses; @@ -3129,14 +3252,20 @@ const reference = (id, path, value, own = null) => { }; const questionReference = (id, question, index) => { const own = question?.key === undefined ? null : `${id}#question.${question.key}`; - const pointer = reference(id, `intent.openQuestions[${index}]`, question, own); - return question?.links?.length ? { pointer, links: question.links } : pointer; + const { links, ...text } = question; + const pointer = reference(id, `intent.openQuestions[${index}]`, text, own); + return links?.length ? { pointer, links } : pointer; }; +const order = ["refines", "dependsOn", "constrainedBy", "decidedBy"]; const cuts = [ () => { answer.boundary = Object.fromEntries(Object.entries(answer.boundary).map(([name, rows]) => [name, rows.map((row) => row.id)])); answer.leftOut.push("Boundary details; ids kept per direction."); }, + () => { + for (const member of members) member.footing = Object.fromEntries(order.map((type) => [type, member.footing.filter((row) => row.type === type).map((row) => row.id)]).filter(([, list]) => list.length)); + answer.leftOut.push("Footing details; ids kept per relation type."); + }, () => { for (const member of members) member.narrative = addressesIn(member.narrative).join(" ") || null; answer.leftOut.push("Member narrative."); @@ -3148,38 +3277,30 @@ const cuts = [ if (behavior.examples) behavior.examples = behavior.examples.map((entry, index) => reference(member.id, `behavior.examples[${index}]`, entry)); if (behavior.exampleSpace) behavior.exampleSpace = reference(member.id, "behavior.exampleSpace", behavior.exampleSpace); } - for (const example of member.examples) example.steps = reference(example.id, "behavior.examples", example.steps); + for (const example of member.examples) if (example.steps !== null) example.steps = reference(example.id, "behavior.examples", example.steps); } answer.leftOut.push("Example step text, including member sections and example-space vocabulary."); }, () => { for (const member of members) { member.questions = member.questions.map((question) => question.blocking ? question : questionReference(member.id, question, question.index)); - if (member.sections.intent?.openQuestions) member.sections.intent.openQuestions = member.sections.intent.openQuestions - .map((question, index) => question?.blocking ? question : questionReference(member.id, question, index)); } - answer.leftOut.push("Non-blocking question text, including member sections; addresses and links kept."); + answer.leftOut.push("Non-blocking question text; addresses and links kept."); }, () => { for (const member of members) { - const pinned = new Set(member.declarations.map((declaration) => declaration.key)); - // A keyed entry is read by its own key, which may contain a dot, never from the joined path. + const pinned = new Set(member.declarations); const keyedEntry = (section, key, value) => section === "design" && pinned.has(key) ? value : reference(member.id, `${section}.${key}`, value, address(member.id, section, key)); const reduce = (value, path) => { - // Protected entries stay whole; everything else becomes a graph location or address. - if (path.startsWith("intent.openQuestions[")) return value; const rule = /^behavior\.(rules|flows)\[(\d+)\]$/u.exec(path); if (rule) { const key = Object.keys(member.sections.behavior.ruleKeys ?? {}).find((key) => member.sections.behavior.ruleKeys[key] === `${rule[1]}[${rule[2]}]`); return reference(member.id, path, value, key === undefined ? null : `${member.id}#rule.${key}`); } if (path === "behavior.ruleKeys") return value; - const isLocation = (entry) => entry && typeof entry === "object" && !Array.isArray(entry) && - typeof entry.spec === "string" && typeof entry.path === "string" && typeof entry.file === "string" && - (entry.line === null || typeof entry.line === "number") && Object.keys(entry).every((key) => ["spec", "path", "file", "line"].includes(key)); - if (isLocation(value)) return value; - if (Array.isArray(value) && value.some(isLocation) && value.every((entry) => isLocation(entry) || + if (isPointer(value)) return value; + if (Array.isArray(value) && value.some(isPointer) && value.every((entry) => isPointer(entry) || typeof entry === "string" && addressesIn(entry).includes(entry))) return value; if (Array.isArray(value)) return value.map((entry, index) => reduce(entry, `${path}[${index}]`)); if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, @@ -3188,12 +3309,19 @@ const cuts = [ }; member.sections = reduce(member.sections, ""); } - answer.leftOut.push("Section entry values other than pinned declarations and blocking questions; addresses and locations kept."); + answer.leftOut.push("Section entry values other than pinned declarations; addresses and lines kept."); }, ]; +if (declarationMode === "addresses") cuts.push(() => { + for (const member of members) for (const key of member.declarations) { + const value = member.sections.design[key]; + member.sections.design[key] = reference(member.id, `design.${key}`, value, address(member.id, "design", key)); + } + answer.leftOut.push("Pinned declaration text; entry addresses kept."); +}); if (measure() <= budget) return answer; for (const cut of cuts) { cut(); if (measure() <= budget) return answer; } -return { scope, budget, budgetExceeded: true, protectedLength: answer.used, leftOut: answer.leftOut }; +return { scope, budget, declarations: declarationMode, budgetExceeded: true, protectedLength: answer.used, leftOut: answer.leftOut }; ``` `scope` states a Pack and whether it is narrowed, with its ordered member ids, or the ordered @@ -3201,22 +3329,26 @@ Spec list. Each member keeps its stated rung, floor and stated next rung, with ` unmet floor clauses. `design` counts entries, declarations and questions, beside shaping decisions and the Pack design's bindings. Questions keep addresses, recorded lines and their fields. `footing` has recipe 21's dependency rows, including `reason` when the relation states one; -`declarations` has recipe 24's pinned declarations. -`examples` lists refining or verifying examples with their rung, enabled binding and steps. +`declarations` lists pinned keys, with each value kept once in `sections.design`. +Each member carries its carrier `file`; questions live only in `questions`, with no second copy +in `sections.intent`. `examples` lists refining or verifying examples with their file, rung, +enabled binding and steps. An example that is itself a member has `member: true` and `steps: null`, +with the steps in its own member row. Bindings carry their claims and recorded source locations. Boundary rows carry declared relations, grouped exactly as the Pack design does. -The fixed cuts are boundary details, member narrative, example step text, non-blocking questions, -then remaining section values. Each cut removes text everywhere it appears, including `sections`, while retaining entry addresses -and question links from that text. -The fifth cut retains pinned declarations and blocking questions whole, reduces keyed entries to -their addresses and other entries to `{ spec, path, file, line }`. If the cut value contains other -entry addresses, they stay beside that pointer in a list. Non-blocking questions retain their -addresses or locations and any links. Null lines mean unlocated. +The fixed cuts are boundary details, footing details, member narrative, example step text, +non-blocking question text, then section values other than pinned declarations. With +`declarations: "addresses"`, a final cut reduces pinned values to entry addresses. The recipe +stops at the first cut after which it fits. Footing becomes target ids grouped by relation type. +Blocking questions stay whole. Other entries keep their addresses or `{ line }`, with the +enclosing row and position naming the Spec, file and path. Entry addresses written in cut text +stay beside its pointer, and question links stay beside the question pointer. Null lines mean +unlocated. `leftOut` lists the cuts taken in that order. `used` counts the compact returned JSON, including itself, `scope`, `budget`, `tokenEstimate` and `leftOut`. The estimate divides code units by four, rounded up. A successful bundle always fits the requested budget. A budget-exceeded result carries -only the scope, budget, protected length and omission list, and the CLI exits 0. Invalid or unknown +only the scope, budget, declaration mode, protected length and omission list, and the CLI exits 0. Invalid or unknown scopes return diagnostics without a bundle. The law is `spec:consumers.context-bundle`. ## 37. The perspective filter @@ -3250,6 +3382,9 @@ contexts must match the same bound unit's component. An absent restriction match an empty restriction matches nothing. ```js +const accepts = ["pack", "perspective"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const packId = params.pack; const perspective = params.perspective; const diagnostics = []; @@ -3371,6 +3506,9 @@ and its type is one of the six authored relations. Scope entries are Spec ids or A proposer, status, date, approval, verdict or any other field is refused. ```js +const accepts = ["proposal", "base"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const proposal = params.proposal; const base = params.base; const schemaProblems = []; @@ -3615,6 +3753,9 @@ returns `{ pack, found: false }`. An existing Pack with no candidates keeps its rows. A row is a potential candidate, never permission to delete an entry. ```js +const accepts = ["pack"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const byCodeUnit = (left, right) => (left < right ? -1 : left > right ? 1 : 0); const pack = params.pack === undefined ? undefined : g.packContext(params.pack); if (params.pack !== undefined && !pack) return { pack: params.pack, found: false }; @@ -3690,6 +3831,9 @@ excluded intermediates, and records the normalized exclusions in its output. Onl component identity and `memberOf` assign files to components. ```js +const accepts = ["imports", "limit"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; const input = params.imports; const diagnostics = []; const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value); @@ -3810,3 +3954,205 @@ units with no component. Component designs are declared intent, bindings are anc imports are inferred. Every mismatch and ranking is advice; the body changes nothing and no finding enters validation. Bad parameters are input errors, not curation findings. The law is `spec:consumers.impact-graph.curation-assist`. + +## 41. The key-family register + +*When you need this: you want a corpus's keyed values, such as limits, error messages or storage +tables, as a register derived each time you read it.* + +`params.prefixes` is a non-empty list of unique non-empty strings, matched case-sensitively against +the start of each Design or UI key. It defaults to `["finding", "impact"]`. Pass at most one scope, +`params.pack` or `params.specs`; without either, the register reads the corpus. A repeated Spec id +is read once. Rule, flow and question keys and `description` are left out. Rows have no cap and +sort by key, Spec id and section in code-unit order. A malformed prefix list or scope returns only +`{ diagnostics: [{ parameter, problem }] }`. + +```js +const accepts = ["prefixes", "pack", "specs"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; +const prefixes = params.prefixes ?? ["finding", "impact"]; +const diagnostics = []; +const bad = (parameter, problem) => diagnostics.push({ parameter, problem }); +if (params.prefixes === null || !Array.isArray(prefixes) || prefixes.length === 0 || !prefixes.every((prefix) => typeof prefix === "string" && prefix.length > 0) || + new Set(prefixes).size !== prefixes.length) bad("prefixes", "Expected a non-empty list of unique non-empty strings."); +if (params.pack !== undefined && params.specs !== undefined) bad("specs", "Supply at most one of pack or specs."); +let scope = { corpus: true }, ids = g.specs().map((spec) => spec.id); +if (params.pack !== undefined) { + const pack = typeof params.pack === "string" ? g.packContext(params.pack) : undefined; + if (!pack) bad("pack", "Pack does not resolve."); + else { + scope = { pack: params.pack }; ids = pack.members.map((member) => member.id); + for (const member of pack.members) if (!member.resolved) bad("pack", `${member.id} does not resolve.`); + } +} else if (params.specs !== undefined) { + if (!Array.isArray(params.specs) || !params.specs.every((id) => typeof id === "string")) bad("specs", "Expected a list of Spec ids."); + else { + for (const id of params.specs) if (!g.specContext(id)) bad("specs", `${id} does not resolve.`); + scope = { specs: params.specs }; ids = [...new Set(params.specs)]; + } +} +if (diagnostics.length) return { diagnostics }; +const byCodeUnit = (a, b) => a < b ? -1 : a > b ? 1 : 0; +const byPrefix = Object.fromEntries(prefixes.map((prefix) => [prefix, 0])); +const rows = []; +for (const id of ids) { + const context = g.specContext(id); + for (const section of ["design", "ui"]) { + for (const [key, value] of Object.entries(context.sections?.[section] ?? {})) { + if (key === "description") continue; + const matched = prefixes.filter((prefix) => key.startsWith(prefix)); + if (matched.length === 0) continue; + for (const prefix of matched) byPrefix[prefix] += 1; + const at = context.entryLocations.find((row) => row.entry === `${section}.${key}`); + rows.push({ key, spec: id, section, value, file: at?.file ?? context.file, line: at?.line ?? null }); + } + } +} +rows.sort((a, b) => byCodeUnit(a.key, b.key) || byCodeUnit(a.spec, b.spec) || byCodeUnit(a.section, b.section)); +return { prefixes, scope, totals: { rows: rows.length, specs: new Set(rows.map((row) => row.spec)).size, byPrefix }, rows }; +``` + +The answer is `{ prefixes, scope, totals: { rows, specs, byPrefix }, rows }`, each row +`{ key, spec, section, value, file, line }`, with `line` null when the location table records none. +`byPrefix` counts a key under every prefix it matches. The law is +`spec:consumers.agent-surface.register-recipes`. + +## 42. The architecture walk + +*When you need this: check forbidden layer directions and isolated contexts against anchored uses.* + +Pass `params.forbidden` as layer pairs `{ from, to }`, `params.isolated` as context tokens, or +both. `params.severity` defaults to `error`. An empty list checks nothing for that form. Missing +attributes appear in `unclassified`; they are curation gaps. With neither list, the answer gives +diagnostics. The same shipped body runs as an operator-selected rules file: + +```sh +pnpm --silent sdp validate . --exclude explorations --exclude examples --exclude test/fixtures/import/parity --rules dist/recipes/42-the-architecture-walk.js --params '{"forbidden":[{"from":"domain","to":"infrastructure"}]}' +``` + +```js +const accepts = ["forbidden", "isolated", "severity"]; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; +const layers = ["edge", "application", "domain", "adapter", "infrastructure"]; +const token = /^[a-z][a-z0-9-]*$/u; +const diagnostics = []; +if (params.forbidden === undefined && params.isolated === undefined) diagnostics.push("Supply params.forbidden or params.isolated."); +const forbidden = params.forbidden === undefined ? [] : params.forbidden; +const isolated = params.isolated === undefined ? [] : params.isolated; +const severity = params.severity ?? "error"; +if (!Array.isArray(forbidden) || forbidden.some((pair) => !pair || typeof pair !== "object" || !layers.includes(pair.from) || !layers.includes(pair.to))) diagnostics.push("forbidden must be a list of { from, to } pairs of layers."); +if (!Array.isArray(isolated) || isolated.some((context) => typeof context !== "string" || !token.test(context))) diagnostics.push("isolated must be a list of lowercase kebab context tokens."); +if (params.severity !== undefined && params.severity !== "error" && params.severity !== "warning") diagnostics.push("severity must be error or warning."); +if (diagnostics.length > 0) return { diagnostics }; +const pairs = [...new Map(forbidden.map(({from, to}) => [`${from}:${to}`, {from, to}])).values()]; +const contexts = [...new Set(isolated)]; +const components = new Map(graph.nodes.filter((node) => node.nodeType === "CodeNode" && node.claim === "anchored" && node.id.startsWith("component:")).map((node) => [node.id, node])); +const membership = new Map(graph.edges.filter((edge) => edge.type === "memberOf" && edge.claim === "anchored").map((edge) => [edge.from, edge.to])); +const componentOf = (id) => components.get(id) ?? components.get(membership.get(id)); +const summary = (node) => node ? { id: node.id, layer: node.layer ?? null, context: node.context ?? null } : null; +const uses = graph.edges.filter((edge) => edge.type === "uses" && edge.claim === "anchored"); +const findings = [], unclassified = []; +let resolved = 0; +for (const { from, to } of uses) { + const a = componentOf(from), b = componentOf(to); + if (a && b) resolved++; + const fromComponent = summary(a), toComponent = summary(b); + if (!a || !b) { + if (pairs.length > 0 || contexts.length > 0) unclassified.push({ from, to, fromComponent, toComponent, missing: ["component"] }); + continue; + } + if (a.id === b.id) continue; + const missing = []; + if (pairs.length > 0 && (a.layer === undefined || b.layer === undefined)) missing.push("layer"); + if (contexts.length > 0 && (a.context === undefined || b.context === undefined)) missing.push("context"); + if (missing.length > 0) unclassified.push({ from, to, fromComponent, toComponent, missing }); + const base = { subjectId: from, relatedId: to, severity, from, to, fromComponent, toComponent }; + for (const rule of pairs) { + if (a.layer === rule.from && b.layer === rule.to) findings.push({ ...base, validatorId: "forbidden-layer", rule, + message: `${from} uses ${to}: layer ${a.layer} uses layer ${b.layer} (${a.id} to ${b.id}), a forbidden pair` }); + } + if (contexts.includes(a.context) && b.context !== undefined && a.context !== b.context) findings.push({ ...base, validatorId: "isolated-context", rule: { context: a.context }, + message: `${from} uses ${to}: ${a.id} in the isolated context ${a.context} uses ${b.id} in the context ${b.context}` }); +} +const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0; +findings.sort((a, b) => compare(a.from, b.from) || compare(a.to, b.to) || compare(a.validatorId, b.validatorId)); +unclassified.sort((a, b) => compare(a.from, b.from) || compare(a.to, b.to)); +return { input: { forbidden: pairs, isolated: contexts, severity }, totals: { usesEdges: uses.length, resolved, findings: findings.length, unclassified: unclassified.length }, findings, unclassified }; +``` + +Findings retain both units, both component attributes and the rule breached. The runner prints the +shared finding fields. The law is `spec:validation.architecture-constraints`. + +## 43. Planned against realized components + +*When you need this: compare component designs with the anchors that realize them.* + +This recipe takes no parameters. It selects Specs whose Design `id` names a component, then reads +only the keys the design states. A bare value or an opening code span carries the machine value; +prose after the span explains it. `uses: none` states an empty set. An omitted `uses` stays null. + +```js +const accepts = []; +const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); +if (unknownParams.length > 0) return { unknownParams, accepts }; +const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0; +const read = (value) => typeof value === "string" ? (/^(`+)(?!`)([^\r\n]+?)(? node.nodeType === "CodeNode" && node.claim === "anchored" && node.id.startsWith("component:")).map((node) => [node.id, node])); +const membership = new Map(graph.edges.filter((edge) => edge.type === "memberOf" && edge.claim === "anchored").map((edge) => [edge.from, edge.to])); +const componentOf = (id) => components.get(id) ?? components.get(membership.get(id)); +const uses = graph.edges.filter((edge) => edge.type === "uses" && edge.claim === "anchored"); +const designs = graph.nodes.filter((node) => node.nodeType === "Primitive" && componentId.test(read(node.sections?.design?.id) ?? "")); +const unrealized = [], differing = [], unreadable = []; +let realizedCount = 0, matching = 0; +for (const spec of designs) { + const entries = spec.sections.design; + const id = read(entries.id); + const planned = { layer: null, context: null, role: null, uses: null }; + const invalid = new Set(); + for (const key of ["layer", "context", "role", "uses"]) { + if (entries[key] === undefined) continue; + const value = read(entries[key]); + let valid; + if (key === "uses") { + const ids = value === "none" ? [] : value?.split(",").map((item) => item.trim()); + valid = ids !== undefined && ids.every((item) => componentId.test(item)); + if (valid) planned.uses = [...new Set(ids)].sort(compare); + } else { + valid = value !== null && (key === "layer" ? layers.includes(value) : token.test(value)); + if (valid) planned[key] = value; + } + if (!valid) { invalid.add(key); unreadable.push({ spec: spec.id, key, value: entries[key] }); } + } + const anchor = components.get(id); + const realized = anchor && graph.edges.some((edge) => edge.type === "satisfies" && edge.claim === "anchored" && edge.from === id && edge.to === spec.id); + if (!realized) { unrealized.push({ spec: spec.id, componentId: id, anchorExists: Boolean(anchor), ...planned }); continue; } + realizedCount++; + const before = differing.length; + for (const field of ["layer", "context", "role", "uses"]) { + if (entries[field] === undefined || invalid.has(field)) continue; + if (field === "uses") { + const actual = new Set(uses.filter((edge) => componentOf(edge.from)?.id === id).map((edge) => componentOf(edge.to)?.id).filter((target) => target !== undefined && target !== id)); + const plannedOnly = planned.uses.filter((target) => !actual.has(target)); + const realizedOnly = [...actual].filter((target) => !planned.uses.includes(target)).sort(compare); + if (plannedOnly.length > 0 || realizedOnly.length > 0) differing.push({ spec: spec.id, componentId: id, field, planned: plannedOnly, realized: realizedOnly }); + } else { + const actual = anchor[field] ?? null; + if (planned[field] !== actual) differing.push({ spec: spec.id, componentId: id, field, planned: planned[field], realized: actual }); + } + } + if (differing.length === before && invalid.size === 0) matching++; +} +unrealized.sort((a, b) => compare(a.spec, b.spec)); +differing.sort((a, b) => compare(a.spec, b.spec) || compare(a.field, b.field)); +unreadable.sort((a, b) => compare(a.spec, b.spec) || compare(a.key, b.key)); +return { totals: { designs: designs.length, realized: realizedCount, matching, unrealized: unrealized.length, differing: differing.length, unreadable: unreadable.length }, unrealized, differing, unreadable }; +``` + +An anchor without an anchored `satisfies` edge stays unrealized. `differing` counts rows; `matching` +counts realized designs with neither differences nor unreadable fields. The law is +`spec:model.planned-architecture`. diff --git a/examples/checkout-v1/README.md b/examples/checkout-v1/README.md index 797d4ede..cebca99a 100644 --- a/examples/checkout-v1/README.md +++ b/examples/checkout-v1/README.md @@ -32,7 +32,7 @@ pointed at. authored example space. - **`generated/`** is untracked output from the walk: `graph.json`, contracts, the registrar - manifest, and all four projections. It is regenerable and never edited by hand. The valid-cart + manifest, and all five projections. It is regenerable and never edited by hand. The valid-cart registrar beside the authored test is the one committed adopted generated sibling because the authored test imports it; preflight byte-checks that exception against fresh generation. @@ -52,7 +52,7 @@ npm run build node ./dist/cli/sdp.js build examples/checkout-v1 # extract, graph, and contracts node ./dist/cli/sdp.js validate examples/checkout-v1 # build, conformance, and honesty checks node ./dist/cli/sdp.js view examples/checkout-v1 # validate and the Design Review -npm run generate:example # publish all four projection roots +npm run generate:example # publish all five projection roots ``` `build` prints an extraction summary and writes the one graph. Its flat nodes and edges retain @@ -109,8 +109,8 @@ parent has no gap. An unbound example that states `ready` still gets a verifies- - **relations and one-hop impact**: the relation list is the blast radius of changing this Spec. `--check-clean` rejects divergent graph, contract, registrar-manifest, registrar, or projection -bytes. `npm run check:example` certifies Design Review, census, Mermaid, and Gherkin roots while -preserving all four despite each public projection verb's build invalidation. Delete `generated/` +bytes. `npm run check:example` certifies Design Review, census, Mermaid, Gherkin, and Studio roots while +preserving all five despite each public projection verb's build invalidation. Delete `generated/` and rerun `npm run generate:example` to reproduce the same bytes. ## Break it on purpose diff --git a/package.json b/package.json index 3eabbcff..4d6243a1 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,10 @@ "types": "./dist/anchors.d.ts", "import": "./dist/anchors.js" }, + "./concept-match": { + "types": "./dist/reader/concept-match.d.ts", + "import": "./dist/reader/concept-match.js" + }, "./runner": { "types": "./dist/runner/index.d.ts", "import": "./dist/runner/index.js" @@ -52,9 +56,11 @@ }, "scripts": { "build": "tsup", + "prepare": "npm run build 1>&2", "typecheck": "tsc --noEmit -p tsconfig.json", "typecheck:examples": "tsc --noEmit -p tsconfig.examples.json", "test": "node ./vitest-test.mjs", + "check:git-install": "node ./check-git-install.mjs", "test:watch": "vitest", "sdp": "node ./dist/cli/sdp.js", "sdp:q": "node ./dist/cli/sdp.js q --exclude explorations --exclude examples --exclude test/fixtures/import/parity", diff --git a/plans/42-spec-studio-phase-zero.md b/plans/42-spec-studio-phase-zero.md index 4c35ee1f..65e121ed 100644 --- a/plans/42-spec-studio-phase-zero.md +++ b/plans/42-spec-studio-phase-zero.md @@ -1,7 +1,7 @@ # Plan 42. The Spec Studio, phase 0: a read-only static view -> **Status:** DRAFTED after plan 41's fold, merged to `main` with PR 30; it is executed on its own -> branch from `main` once PR 31 merges. A thin lineage +> **Status:** ✅ EXECUTED as plan 45's lane S, in PR 33; drafted after plan 41's fold and merged +> to `main` with PR 30. A thin lineage > pointer: the Studio's intent lives in `pack:spec-studio-v1`, and its readiness, open questions and > backlog are read from the graph (recipes 1, 9, 11, 20, 29), never from this file. If this file and > the graph disagree, the graph wins and this file is stale. @@ -22,7 +22,8 @@ The owner ruled the Studio's re-entry on 2026-10-12, on the PR 30 ruling sheet: history contract, locate OD-157, explain the aggregate dependency, find one declaration's type comparison, and say whether a paused-rebuild example has native-backend evidence. Phase 0 reads no run evidence, so the last task passes when the reader finds that the example requires - the native backend tier and has a bound verifier, and reports that no run result is supplied. + the native backend tier, reads whether a verifier is bound to it, and reports that no run result + is supplied. `spec:consumers.spec-studio` states the same tasks and the same access boundary. - **The home.** The read-only Studio ships in `@libar-dev/software-delivery-protocol`; hosted collaboration may later live under `@libar-ai/`. diff --git a/plans/45-design-at-the-adopters-scale.md b/plans/45-design-at-the-adopters-scale.md new file mode 100644 index 00000000..13bb0260 --- /dev/null +++ b/plans/45-design-at-the-adopters-scale.md @@ -0,0 +1,173 @@ +# Plan 45. Design at the adopter's scale + +> **Status:** ✅ EXECUTED on `feature/design-at-scale-v0`, from `main` at `7eef29b`, the merge of +> PR 32, as PR 33; the platform's side is `sdp/design-at-scale` of libar-platform. Every lane landed, +> each reviewed across model families, and the rulings below were made on the owner's delegation +> and await the owner's review. A thin lineage pointer: readiness, backlog and open questions are +> read from the graph (recipes 1, 9, 11, 20, 29, 33), never from this file. If this file and the +> graph disagree, the graph wins and this file is stale. + +## Why + +The first adopter, libar-platform, designs a Convex transactional domain platform as a corpus of +about 240 Specs in six Packs. It is a large design, and most of it will stay design for months: +layers 3 to 6 exist only on paper. On 2026-10-13 the owner asked for the next chunk to unlock as +much of the Protocol as possible for that design work, without machinery no reader needs. + +A read of the platform, its feedback and the Protocol's backlog found four needs, in order: + +1. **Reach `main` cheaply.** The platform pins the Protocol at `822aea4`, a vendored tarball from + before PRs 30 to 32. About fifteen of its asks are delivered on `main` and unusable at its pin. + Main's engine validates its corpus identically and passes its tests; one script must change. +2. **Design passes that fit a model's budget.** The context bundle (recipe 36) overflows its + default budget on every platform Pack: most of the cut bundle is repeated locations and + pinned declarations carried twice. +3. **A page a person can read.** The owner wants to present designs ahead of their code, and the + platform's reviewers read carrier files because the Markdown Pack page is a 13-column table. + This is the Spec Studio's phase 0 (plan 42). +4. **The platform's own rules inside `sdp validate`.** The platform's gate (`design/tools/check.py`) + parses carriers to check citation edges, isolation and readiness policy. Corpus rules and + architecture constraints give those checks a Protocol home; planned architecture lets layers 3 + to 6 state their components before their code. + +## The lanes + +| Lane | Delivers | Kind | Models | +| --- | --- | --- | --- | +| **P** the adopter on-ramp | Install from a commit; `bindExample` runs `after` in a `finally`; `check-carrier-rule.mjs` wired or deleted; an adopter migration note | build | gpt-6.1-sol, reviewed by Opus 5.5 | +| **L** the loop at scale | A compact context bundle; `declarations: "addresses"`; recipe 41, the key-family register; a diagnostic for a parameter a recipe does not take | design, then build | Opus 5.5 designs, gpt-6.1-sol builds, Opus 5.5 reviews | +| **B** corpus checks | Design: keyed Design entries for corpus rules, architecture constraints and planned architecture, and `pack:corpus-checks-v1`. Build: `sdp validate --rules`, recipe 42 (the architecture walk), recipe 43 (planned against realized components) | design, then build | Opus 5.5 designs, gpt-6-sol reviews; gpt-6.1-sol builds | +| **S** the Studio's phase 0 | Plan 42's design pass (child Specs, the phase-0 questions, a Pack-page prototype, the data and element contracts), then its lanes S1 to S3 | design, then build | Opus 5.5 designs, Fable 5.1 and gpt-6-sol review; gpt-6.1-sol builds | +| **E** the platform on `main` | In libar-platform: the re-pin, its migration, the tooling the Protocol now replaces, and the two associations the Studio's reader tasks need | adopter build | gpt-6.1-sol, reviewed by Opus 5.5 | +| **X** integration and close | Recipe catalog, self-hosting oracle, skills, `CONTEXT.md`, help text; an adversarial review; the Studio's acceptance on the platform; the PR | close | gpt-6.1-sol, gpt-6-sol, gpt-6-astra | + +Wave 1 runs P, L and the design passes of B and S in parallel worktrees. The main thread then +rules on what the design passes recommend and states the `ready` the builds start from. Wave 2 +builds B and S. Wave 3 integrates, reviews and measures. Recipe numbers are pre-assigned so lanes +do not collide: 41 to L, 42 and 43 to B. + +When the platform needs a capability, the Protocol builds it; the platform does not work around +it. Lane **E** then moves the platform onto the chunk's head in its own repository, on the owner's +word of 2026-10-13: install from the commit, apply the migration the read found, and retire the +platform's home-grown tooling where the Protocol now serves (the graph-side half of `check.py` as +rules files, recipe bodies cut from the catalog by regular expression, the hand-built Pack page). +A gap E finds comes back to the Protocol as a fix in this chunk. + +## Rulings at the start + +Made by the main thread on the owner's delegation, before the lanes; each awaits the owner's +review. + +| Ruling | Carrying Spec | +| --- | --- | +| One branch and one PR for the chunk; plan 42 is lane S's brief | — | +| An adopter installs from a commit through a `prepare` script that builds the package; no npm prerelease while the version is `0.0.0` | `spec:consumers.adopter-on-ramp` | +| The context bundle may cut pinned declarations to entry addresses, opt-in through `params.declarations`; a partial bundle stays refused | `spec:consumers.context-bundle` | +| The key-family register is one recipe and needs no model change | `spec:consumers.agent-surface.register-recipes` | +| A parameter name a recipe does not take is reported in its answer, never ignored | `spec:consumers.agent-surface.recipe-parameters` | +| A corpus-checks Pack holds corpus rules, architecture constraints and planned architecture | `spec:consumers.graph-first-planning` | +| The provisional answers on corpus rules and architecture constraints stand | `spec:validation.corpus-rules`, `spec:validation.architecture-constraints` | +| Choosing a provider stays prose; one runtime owner per concern is a corpus's own rule, not a Protocol shape | `spec:model.planned-architecture` | +| The provisional answers on the Studio's Specs stand, `spec-page#sourceLinks` among them; phase 0 has no AI slices | `pack:spec-studio-v1` | +| The vault is its own projection, not the Studio's local half, and waits for a reader | `spec:consumers.obsidian-vault` | + +## Rulings after wave 1 + +Wave 1 merged at `af30233`. Each design pass was reviewed across model families (B by gpt-6-sol; +S by gpt-6-sol and Fable 5.1; L and P by Opus 5.5) and fixed before the main thread ruled. These +rulings were delegated, and each awaits the owner's review. + +| Ruling | Carrying Spec | +| --- | --- | +| A cut entry without an entry address keeps only `{ line }`; its row and its place in the bundle name the rest | `spec:consumers.context-bundle#design.pointer` | +| A parameter name a recipe does not take replaces the whole answer with `{ unknownParams, accepts }` | `spec:consumers.agent-surface.recipe-parameters#rule.unknownNames` | +| A rules file is one `sdp q` body returning `{ findings }`, so a recipe that returns findings is a rules file as shipped | `spec:validation.corpus-rules#design.ruleAnswer` | +| A rule id is one kebab token, never a Protocol validator id; a corpus finding names its rule and rules file | `spec:validation.corpus-rules#design.ruleFinding` | +| Each `--params` belongs to the `--rules` before it; each file gets its own copy of the graph and report; one path named twice is refused | `spec:validation.corpus-rules#design.paramsFlag` | +| `validate` exits 1 on any Protocol error, corpus error or failed rules file; the two summary lines say which | `spec:validation.corpus-rules#design.exitCodes` | +| Under `--watch`, rules files are read again each cycle; editing one does not start a cycle | `spec:validation.corpus-rules#rule.watchReruns` | +| A component design states an empty `uses` as `none` | `spec:model.planned-architecture#design.designKeys` | +| The Studio publishes static pages that read without script, except search; elements only enhance | `spec:consumers.spec-studio#rule.staticPageSet` | +| Search lives on one page that embeds the index and shares `findByConcept`'s matcher, adding coined keys and file paths | `spec:consumers.spec-studio.search` | +| A Pack page leads with its computed summary, folds the holds into it, and gives each card one colored badge | `spec:consumers.spec-studio.pack-page` | +| The fifth reader task passes when the reader finds whether a verifier is bound, since the platform binds none there | `spec:consumers.spec-studio` | +| The Studio joins the repository's projection suite; element tests run under Node without a DOM library | `spec:consumers.spec-studio.publisher` | + +Stated on the same delegation, each at the floor recipe 9 reported: `ready` for corpus rules, +architecture constraints, planned architecture, and the Studio's data, shell, search, publisher, +index page, Pack page, Spec page core and page elements; `defined` for the Studio's lenses, Spec +page, components and responsive layout. Their build lands in wave 2; until then each `ready` one +reports an honest verifier gap. + +Wave 2 runs in parallel: an integration pass (the oracle, the shared skills and the line a +mention in a rule loses), the corpus-checks build, the Studio's data and verb beside its elements +and then its pages, and lane E's first half, which moves the platform to this commit. Lane E's +second half follows the builds: rules files and the Studio on the platform. + +## Rulings after wave 2 + +Wave 2 merged at `eef8ba9`. Each build was reviewed by Opus 5.5, and the Studio's pages also by +Fable 5.1, before the main thread ruled. Delegated; each awaits the owner's review. + +| Ruling | Carrying Spec | +| --- | --- | +| Search indexes only the graph; an adopter's register id is found once the adopter writes it into a Spec, so the second reader task keeps its wording | `spec:consumers.spec-studio.search` | +| A keyed question on a Spec page shows its key, with a copy action for the full address | `spec:consumers.spec-studio.spec-page.core#ui.questionItem` | +| The left rail lists example-space owners only on Pack and Spec pages | `spec:consumers.spec-studio.shell#ui.leftRail` | +| An example states a component design no anchor realizes as `realizedLayer: "absent"` | `spec:model.planned-architecture` | +| The rules runner lives in the CLI; the validation layer holds only the corpus finding types | `spec:validation.corpus-rules` | +| The concept matcher ships as the browser-safe subpath `./concept-match`, as the anchor builders ship as `./anchors` | `spec:consumers.spec-studio.search` | + +## Rulings after wave 3 + +Two adversarial reviews (gpt-6-sol on the code, Opus 5.5 on the design's honesty) and the +platform's second half found the last defects; each was fixed with a test that failed first. +Delegated; each awaits the owner's review. + +| Ruling | Carrying Spec | +| --- | --- | +| A build from a git archive, as npm builds a GitHub pin, records its commit through `export-subst`, so `sdp --version` names the pinned commit on every install route | `spec:consumers.engine-provenance` | +| A corpus finding's `file` is a root-relative POSIX path; one rules file named twice is refused through a symlink too | `spec:validation.corpus-rules#design.ruleFinding` | +| The key-family register answers an unresolved Pack member with a diagnostic, never a partial register | `spec:consumers.agent-surface.register-recipes#design.familyRecipe` | +| Lane E keeps the platform's committed Pack page beside the Studio: the platform's decision 58 makes it the page a pass reads first, and replacing it is the owner's call | — | + +## Acceptance, measured at close + +- `npm run check` passes; the Markdown Design Review, census, Mermaid and Gherkin projections are + unchanged, and the Studio joins the projection suite as a fifth root. +- The platform's history-view Pack fits the context bundle's default budget with + `declarations: "addresses"`: 93,806 of 100,000 units, on the platform itself. +- The graph-side half of the platform's `check.py` runs as `design/rules/policy.js` and recipe 42 + under `sdp validate --rules`, in the platform's own gate, and every planted mistake its tests + carry still fails it. +- An Astra job given only the platform's Studio pages answered four of the five reader tasks from + them. The fourth, a declaration's type comparison, is met in part: the page names the suite that + compares the Pack's declarations and its file and line, but no page shows the assertion for one + declaration, since no link leaves the page set. + +## Deferred, with their triggers + +| Item | Comes back when | +| --- | --- | +| A context bundle for the platform's application and foundation Packs, which stay over budget after every cut | A design pass needs either Pack whole; until then, read them through a perspective (recipe 37) | +| Watching rules files under `--watch` | An author asks for a rerun on editing a rules file | +| Exporting `sdp q`'s body evaluator from the package, so a script holding a graph runs shipped recipes without deriving it again | An adopter script other than the Pack page the Studio retires needs it | +| Address resolution and cycle search a rules file can call, as a reader verb or as `findings` answers from recipes 25 and 26; the platform's rules file keeps a copy of both, its section list read from the package | The next arc's design pass, first lane: a second rules file needs either | +| A Studio view of one declaration's comparison, from its pin to the assertion that checks it | A reader task needs the assertion, not only the suite's file and line | +| Run evidence | The platform's CI writes CTRF for its native tier, or the Studio's verification panels open | +| Example-space coverage | A design pass asks which slot values no example exercises | +| The Obsidian vault | A reader asks for it after the Studio's phase 0 ships | +| Lineage roots and checked external references | The owner rules what of the earlier system the platform may cite | +| A readiness-statement planner | The owner cannot order the platform's `ready` statements with recipes 9, 21, 29 and 30 | +| Wording-stable step keys | Rewording breaks bound examples during the platform's next slices | + +## Acceptance + +- `npm run check` passes; the Markdown Design Review, census, Mermaid and Gherkin projections are + unchanged. +- The platform's history-view Pack fits the context bundle's default budget with + `declarations: "addresses"`, measured read-only on a copy of the platform. +- The graph-side half of the platform's `check.py` runs as rules files under + `sdp validate --rules`, on a copy of the platform. +- An Astra job given only the Studio pages built from the platform, and the files they link to, + completes plan 42's five reader tasks, with the two associations applied on its copy. diff --git a/projection-suite.mjs b/projection-suite.mjs index 40b36110..804aa4ab 100644 --- a/projection-suite.mjs +++ b/projection-suite.mjs @@ -7,6 +7,7 @@ const projectionCommands = [ ["census", "census"], ["mermaid", "mermaid"], ["gherkin", "gherkin"], + ["studio", "spec-studio"], ]; const argv = process.argv.slice(2); diff --git a/reviews/assets/pr33/README.md b/reviews/assets/pr33/README.md new file mode 100644 index 00000000..f37dbe18 --- /dev/null +++ b/reviews/assets/pr33/README.md @@ -0,0 +1,23 @@ +# PR 33's screenshots + +The Spec Studio's pages, as `sdp studio` publishes them, opened from `file://` in headless Chrome +with no network. The page set is generated and never committed, so these captures stand in for it +on the pull request. + +- `protocol-*.png`: the Protocol's own corpus at `30f196a`, published from the repository root with + `node dist/cli/sdp.js studio . --exclude explorations --exclude examples --exclude test/fixtures/import/parity`. +- `platform-*.png`: the first adopter, libar-platform, on its branch `sdp/design-at-scale` at + `f8a9805`, which pins the Protocol at `30f196a`, published with `npx sdp studio .`. + +| File | Page | Width | +| --- | --- | --- | +| `protocol-index.png` | `index.html` | 1280 | +| `protocol-pack.png` | `packs/corpus-checks-v1.html` | 1280 | +| `platform-pack.png` | `packs/history-view.html` | 1280 | +| `platform-pack-phone.png` | `packs/history-view.html` | 390 | +| `platform-question.png` | `specs/application.history-rebuild.html#question.verifyPassUnderPause` | 1280 | +| `platform-search.png` | `search.html#q=OD-157` | 1280 | +| `platform-example.png` | `specs/application.history-rebuild.write-pause-rebuild-interrupt.html` | 1280 | + +Each capture is the visible viewport at device scale 1. To see the pages themselves, run the +publish command above and open `generated/spec-studio/index.html`. diff --git a/reviews/assets/pr33/platform-example.png b/reviews/assets/pr33/platform-example.png new file mode 100644 index 00000000..a6b73f51 Binary files /dev/null and b/reviews/assets/pr33/platform-example.png differ diff --git a/reviews/assets/pr33/platform-pack-phone.png b/reviews/assets/pr33/platform-pack-phone.png new file mode 100644 index 00000000..91482680 Binary files /dev/null and b/reviews/assets/pr33/platform-pack-phone.png differ diff --git a/reviews/assets/pr33/platform-pack.png b/reviews/assets/pr33/platform-pack.png new file mode 100644 index 00000000..4b7774b6 Binary files /dev/null and b/reviews/assets/pr33/platform-pack.png differ diff --git a/reviews/assets/pr33/platform-question.png b/reviews/assets/pr33/platform-question.png new file mode 100644 index 00000000..e4eecb22 Binary files /dev/null and b/reviews/assets/pr33/platform-question.png differ diff --git a/reviews/assets/pr33/platform-search.png b/reviews/assets/pr33/platform-search.png new file mode 100644 index 00000000..1a935463 Binary files /dev/null and b/reviews/assets/pr33/platform-search.png differ diff --git a/reviews/assets/pr33/protocol-index.png b/reviews/assets/pr33/protocol-index.png new file mode 100644 index 00000000..081a04d9 Binary files /dev/null and b/reviews/assets/pr33/protocol-index.png differ diff --git a/reviews/assets/pr33/protocol-pack.png b/reviews/assets/pr33/protocol-pack.png new file mode 100644 index 00000000..e8b15815 Binary files /dev/null and b/reviews/assets/pr33/protocol-pack.png differ diff --git a/specs/consumers/adopter-on-ramp.sdp.md b/specs/consumers/adopter-on-ramp.sdp.md index 8914a8ef..a61788b7 100644 --- a/specs/consumers/adopter-on-ramp.sdp.md +++ b/specs/consumers/adopter-on-ramp.sdp.md @@ -17,13 +17,16 @@ relations: --- # An adopter learns graph homes, carrier grammar and shipped paths from the on-ramps -The help names the on-ramps by path because an operator can reach the CLI without a package runner and still needs to find the shipped on-ramps and the law they cite. +The help names the on-ramps by path because an operator can reach the CLI without a package runner and still needs to find the shipped on-ramps and the law they cite. A commit pin lets an adopter take any reviewed revision without a registry release, and the package builds itself during that install, so a re-pin is one dependency edit rather than a rebuilt vendored tarball. ## Intent -- problem: An adopter that follows the shipped on-ramps cannot learn where a deferral, an unsettled fact, a ruling awaiting its owner, a statement a test checks, or a count belongs, so it invents tags of its own, hand-kept registers, and scripts that keep them true. -- outcome: Teach each ruled home in the shipped skills, one line each with its carrying Spec, so an adopter states those truths in the graph and not beside it. +- problem: An adopter needs a repeatable commit install and stable recipe inputs. An adopter that follows the shipped on-ramps cannot learn where a deferral, an unsettled fact, a ruling awaiting its owner, a statement a test checks, or a count belongs, so it invents tags of its own, hand-kept registers, and scripts that keep them true. +- outcome: Teach commit installation and stable recipe inputs in the README. Teach each ruled home in the shipped skills, one line each with its carrying Spec, so an adopter states those truths in the graph and not beside it. ## Behavior +- rule: [#commitInstall] An adopter can pin the package to a git commit, and the package's `prepare` script builds the CLI, subpath exports and shipped recipes before installation completes. While the package version is `0.0.0`, the installation guidance uses a commit pin rather than an npm prerelease. +- rule: [#installGuidance] The shipped README teaches a commit install with npm and pnpm, the source-qualified build approval each needs, and a check that the installed CLI's `sdp --version` names the pinned full commit, for a git clone or a hosted git archive install. +- rule: [#recipeUpgrade] The upgrade guidance teaches a recipe caller to pass the recipe's subject as `--params` data and run the shipped recipe file, never to rewrite the recipe body. - rule: The authoring skill teaches a deferral as a blocking open question whose `trigger:` field names what brings the Spec back, plus `dependsOn` for a true precondition. Its blocking question holds the Spec below `defined` without an adopter-specific tag. Example children may still state `defined` once their bound points are complete and match the parent's example vocabulary when it has one; their `ready` waits until the parent states at least `defined`. - rule: It teaches an unsettled fact as a constraint Spec whose blocking open question names the check that would settle it. Specs bounded by it declare `constrainedBy`; they may state `defined` when their own floor clears, and their `ready` waits until the constraint states at least `defined`. - rule: It teaches a ruling that awaits its owner as a decision Spec below `ready`, with `decidedBy` from each Spec the ruling shapes. diff --git a/specs/consumers/agent-surface.answer-shapes.sdp.md b/specs/consumers/agent-surface.answer-shapes.sdp.md index 0b10cf0e..bc3d4ead 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 inside its own body, `const answer = await (async () => { })();`, and returns a filtered value; 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 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. diff --git a/specs/consumers/agent-surface.example-space-coverage.sdp.md b/specs/consumers/agent-surface.example-space-coverage.sdp.md index 70f2c9d1..61ca747e 100644 --- a/specs/consumers/agent-surface.example-space-coverage.sdp.md +++ b/specs/consumers/agent-surface.example-space-coverage.sdp.md @@ -21,7 +21,7 @@ The original design rendered a coverage heatmap from each example space: which c ### Open questions - [non-blocking #combinationCoverage] Is coverage per slot value enough, or does a design pass need the combinations of two or more slots, as the original heatmap drew them? - decider: owner - - links: spec:consumers.spec-studio.verification-panels#question.coverageSource + - links: spec:consumers.spec-studio.verification-panels#ui.coverageGrid - provisional: the recipe reports per-slot literal-union values - [non-blocking #coverageShape] What reader field names and decoded slot and bound-point shapes, recipe parameters and result rows, eligible-example relation policy, and treatment of an unbound slot form the coverage contract? - decider: owner diff --git a/specs/consumers/agent-surface.recipe-parameters.sdp.md b/specs/consumers/agent-surface.recipe-parameters.sdp.md index 1e980652..78a4dfbb 100644 --- a/specs/consumers/agent-surface.recipe-parameters.sdp.md +++ b/specs/consumers/agent-surface.recipe-parameters.sdp.md @@ -9,6 +9,8 @@ relations: --- # Recipes take parameters as data and ship as runnable files +A body reads its parameters by name, so a name it does not read would be dropped without a word, and a recipe with a sample would answer for the sample: a misspelled name would look like a successful call. A recipe therefore answers a name it does not take with that name and the names it does take, and nothing else. A body cannot import and the reader holds no recipe metadata, so each body declares its own names in its first statement, and the recipe check holds that declaration to the catalog. + ## Intent - problem: A recipe that takes its input in its own source makes a caller rewrite the body for each input, a long list included, and a script that runs a recipe must first cut its body out of the Markdown catalog. - outcome: Pass a recipe's parameter as data through the one evaluation sink, and ship every catalog body as a file a caller runs as written. @@ -18,4 +20,12 @@ relations: - rule: `--params` is repeatable in three forms: a JSON object, `@PATH` naming a file that holds one, and `KEY=@PATH` binding the JSON value in the file, of any type, to `params.KEY`, with `KEY` on the lower-camel grammar. Objects merge at the top level from left to right, and a key given twice by any form is refused before the body runs, with exit 1, one line on stderr and no output on stdout, so a recipe takes a large file such as a base graph beside its other parameters. - rule: The bindings `g`, `graph` and `report` keep their names and meaning beside `params`. The sink evaluates one operator-supplied body and adds no query vocabulary: a parameter is input to a body, never a verb. - rule: Every recipe reads its parameters under the names the catalog states. A parameter with a documented sample falls back to it when absent, so each body runs as written; a required input with no sample, such as a base graph, a proposal or an imports file, returns diagnostics when absent. Recipe 4 reads its changed files from `params.files`. +- rule: [#declaredNames] Each catalog body opens by declaring the parameter names it takes, in the order the catalog's index lists them, and checks `params` against that list before it reads anything else; a recipe that takes no parameter declares an empty list. +- rule: [#unknownNames] A recipe given a parameter name it does not take returns `{ unknownParams, accepts }` as its whole answer, and `sdp q` exits 0; it never answers for its sample, for an empty input or for the names it does take in that case. +- rule: [#namesChecked] The recipe check holds each body's declared names to the catalog's index and to the `params` names the body reads, and runs each body with a name it does not take. - rule: The package build writes each catalog body to `dist/recipes/-.js`, where `NN` is the two-digit recipe number and the slug is the recipe's heading in lower kebab case, and the package ships that directory. The catalog stays the one owner of the bodies; the files are derived, and the recipe test checks that each file equals its catalog body byte for byte. + +## Design + +- acceptsPreamble: `const accepts = [...]; const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); if (unknownParams.length > 0) return { unknownParams, accepts };` opens every catalog body as three statements on three lines, with the recipe's names in the brackets as double-quoted strings. +- unknownAnswer: `{ unknownParams, accepts }` is the whole answer for a name a recipe does not take, where `unknownParams` lists every such name in the order `params` holds them, and `accepts` repeats the recipe's declared names. diff --git a/specs/consumers/agent-surface.register-recipes.sdp.md b/specs/consumers/agent-surface.register-recipes.sdp.md index 0d6d816a..c5eaef51 100644 --- a/specs/consumers/agent-surface.register-recipes.sdp.md +++ b/specs/consumers/agent-surface.register-recipes.sdp.md @@ -12,9 +12,13 @@ relations: reason: the open-question register lists each question's fields - target: spec:model.relation-reasons reason: the mention audit reads a caller's intended mentions with their reasons + - target: spec:extraction.entry-locations + reason: the key-family register gives each row the line the location table records --- # Register questions stay executable graph recipes +A corpus keeps registers of its own values, such as its limits, its error messages and its storage tables, as families of keyed Design entries whose keys share a prefix: `limit`, `error`, `table`. The key-family register lists one or more families across a scope each time it runs, so no one keeps those tables by hand. It reads Design and UI entries only. A rule or flow key names a law that an example exercises, and its text is a statement of law rather than a value two parties hold to. Rule coverage and entry search already list rule keys, the open-question register lists question keys, and a register that mixed laws with values would hide which rows are values. + ## Intent - problem: An adopter that keeps open-question tables, dependency lists and reference checks by hand lets each copy go stale. - outcome: Answer register, dependency, and mention questions with catalog recipes, so the tables an adopter would keep by hand are derived on demand. @@ -28,5 +32,13 @@ relations: - rule: Entry search gives an entry its address when one exists: `spec:#
.` for a `design` or `ui` key matching `^[a-z][A-Za-z0-9]*$` other than `description`, `spec:#question.` for the text of an open question that carries a key, and `spec:#rule.` for a keyed rule or flow; every other entry has `address: null`. - rule: Whole tokens are maximal runs of Unicode letters and digits, split at camelCase humps and compared without case; a multiword term matches only as a consecutive run in the same order inside one key or text. - rule: Pinned declarations reports each keyed Design entry whose value opens with a code span, giving the Spec, the key, and the span content as authored with the count of entries and of Specs, and parses no language inside the span. +- rule: [#keyFamily] The key-family register lists every keyed Design and UI entry whose key starts with one of the given prefixes, across the corpus, the members of one Pack, or a list of Specs, each with its Spec, section, key, value as the graph holds it, file and line, with no row cap. +- rule: [#familySections] The key-family register never lists a rule, flow or question key, or the `description` entry. - rule: Each body composes the reader and adds no reader join or CLI verb. - rule: The recipe check executes each catalog body as written, and the on-ramps point to the catalog for its contents. + +## Design + +- familyRecipe: recipe 41, the key-family register, takes `params.prefixes`, a non-empty list of unique non-empty strings, each matched case-sensitively against the start of a key as authored, with `["finding", "impact"]` when absent, and at most one scope, `params.pack` or `params.specs`, reading the whole corpus with neither and a listed Spec once however often it is listed; a malformed prefix list, both scopes, or a Pack or Spec id that does not resolve returns `{ diagnostics: [{ parameter, problem }] }` and no register, `parameter` naming the parameter at fault and `specs` when both scopes are given. A Pack member that does not resolve returns a `pack` diagnostic naming that member, and no register. +- familyShape: `{ prefixes, scope, totals: { rows, specs, byPrefix }, rows }` is the register, where `scope` is `{ corpus: true }`, `{ pack }` or `{ specs }`, `totals.specs` counts the Specs with a row, and `byPrefix` maps each prefix, in the given order, to the count of rows it matches, a key that matches two prefixes counting under each. +- familyRow: `{ key, spec, section, value, file, line }` is one row, with `section` either `design` or `ui`, and `line` null when the location table records no line for the entry; rows sort by key, then Spec id, then section, in code-unit order. diff --git a/specs/consumers/context-bundle.sdp.md b/specs/consumers/context-bundle.sdp.md index f151cbf0..dc256b2f 100644 --- a/specs/consumers/context-bundle.sdp.md +++ b/specs/consumers/context-bundle.sdp.md @@ -14,16 +14,13 @@ The original design precomputed graph slices for different reading scopes and a The first adopter's design passes assemble the same reading material by hand: the Pack design, member questions, dependency footing, pinned declarations and carrying Spec text where the graph answer falls short. Its perspectives read the same Pack for different concerns. The agent front door composes a bundle on each invocation; the bundle is never a committed answer in the graph's name. It builds on Pack design. +At an adopter's scale a bundle is mostly structure, so its shape is compact. Each member and each example row names its carrier file once. A cut entry without an entry address keeps only its line, since its row and its place in the bundle already name it. A question, a pinned value or a member example's steps appears in one place. Pinned declarations stay whole by default, because a builder and a test author hold to them. A caller whose scope still does not fit may let the bundle cut them to their entry addresses, and then reads each one in its Spec when the pass needs it. A partial bundle stays refused, because a bundle missing members would read as the whole scope. + ## Intent - actor: An agent starting one design pass over a Pack or an ordered list of Specs. - problem: Each pass assembles the same reading material by running the same recipes and joining their answers by hand, and nothing bounds what it hands the model. - outcome: Give an agent the reading material for one design pass in one composed slice of the graph within a stated budget, so no session hand-assembles it. -### Open questions -- [non-blocking #declarationCut] No cut reduces a pinned declaration, so a Pack whose pinned declarations alone exceed the budget fits no bundle; may a caller ask the bundle to reduce pinned declarations to their entry addresses? - - decider: owner - - provisional: pinned declarations stay whole, and a caller narrows the scope to fewer members, such as one perspective's - ## Behavior - rule: A bundle is a catalog recipe that takes its scope and its budget as recipe parameters; it adds no entrance and no query verb. - rule: A bundle is composed from the graph on each invocation by the reader every recipe uses; nothing is precomputed, saved or committed, and two invocations over the same graph with the same parameters compose the same bundle. @@ -31,7 +28,9 @@ The first adopter's design passes assemble the same reading material by hand: th - rule: With `pack`, a bundle may take `members`, an ordered subset of the Pack's member ids, which the perspective filter returns; it keeps the Pack's framing and boundary, lists only those members in that order, and states that it is a subset, so one filter implementation serves both recipes. - rule: A bundle for a Pack carries the Pack design assembly, its members in authored order with their design columns and its boundary, then each member's open questions with their addresses and lines, its dependency footing, its pinned declarations and its examples. - rule: The budget is a hard limit on a successful bundle, counted in UTF-16 code units, the `length` a recipe body reads, of the compact `JSON.stringify` of the value the recipe returns, its scope, budget and left-out statement included; `sdp q --json` prints a longer indented form, and the budget never measures printed text. The bundle also reports a token estimate labeled as an estimate, so no model's tokenizer binds the Protocol. -- rule: [#cutOrder] When the scope exceeds the budget, the bundle cuts in a fixed order: boundary rows down to their ids, member narrative, example step text, non-blocking questions, then remaining section values other than protected content. It never cuts member ids, the stated rung, the floor reached, the stated next rung, blocking questions, pinned declarations or entry addresses. +- rule: [#oneCopy] A bundle carries a member's open questions only in its `questions`, a pinned declaration's value only in its member's Design section, and an example's steps only in its own member row when the example is a member of the bundle; an example outside the bundle keeps its steps in the row of each member it refines or verifies. +- rule: [#cutOrder] When the scope exceeds the budget, the bundle cuts in a fixed order and stops at the first cut after which it fits: boundary rows down to their ids, footing rows down to their ids, member narrative, example step text, non-blocking question text, remaining section values other than pinned declarations, then, only when the caller allows it, pinned declarations down to their entry addresses. It never cuts member ids, the stated rung, the floor reached, the stated next rung, blocking questions or entry addresses, and it cuts nothing else. +- rule: [#declarationAddresses] A caller allows the last cut by passing `declarations: "addresses"`; without it, or with `declarations: "whole"`, pinned declarations stay whole, and any other value is a diagnostic result. - rule: When the protected content alone exceeds the budget after every permitted cut, the recipe returns a budget-exceeded result as data and no partial bundle: the requested scope, the budget, and the minimum count the protected content needs in the same unit; `sdp q` exits 0, because the result is the body's return and not a refusal. - rule: A bundle gives the file and line of an entry or a binding in place of a source excerpt, because the reader never reads source. - rule: A bundle states its scope, its budget and what it left out to stay within the budget, so a reader knows what to read in the Specs themselves. @@ -39,10 +38,17 @@ The first adopter's design passes assemble the same reading material by hand: th ## Design -- bundleRecipe: recipe 36, the context bundle, takes exactly one scope, `params.pack` or `params.specs`, an optional `params.members` beside `pack`, and `params.budget`, 100,000 code units by default; a missing, doubled or unknown scope returns `{ diagnostics }` and no bundle. -- bundleShape: `{ scope, budget, used, tokenEstimate: { value, method }, leftOut, pack, members, boundary }`, where `pack` is `{ id, title, framing }` for a Pack scope and null otherwise, and `tokenEstimate.method` reads `UTF-16 code units divided by four, an estimate`. -- memberShape: `{ id, title, specKind, altitude, statedReadiness, floorReached, statedNextRung, holds, narrative, sections, questions, footing, declarations, examples }`, with questions carrying their address, blocking flag, line and fields, footing as dependency footing (recipe 21) reports it, declarations as pinned declarations (recipe 24) report them, and examples with their stated rung, whether they are enabled and their steps. `holds` lists the member's stated-next-rung failures as the reader returns them, each with its `clauseId` and `description`, and with `targets` on a typed-dependency failure. +- bundleRecipe: recipe 36, the context bundle, takes exactly one scope, `params.pack` or `params.specs`, an optional `params.members` beside `pack`, `params.budget`, 100,000 code units by default, and `params.declarations`, `"whole"` by default or `"addresses"`; a missing, doubled or unknown scope, or another `declarations` value, returns `{ diagnostics }` and no bundle. +- bundleShape: `{ scope, budget, declarations, used, tokenEstimate: { value, method }, leftOut, pack, members, boundary }`, where `declarations` is the mode in force, `pack` is `{ id, title, framing }` for a Pack scope and null otherwise, and `tokenEstimate.method` reads `UTF-16 code units divided by four, an estimate`. +- memberShape: `{ id, file, title, specKind, altitude, statedReadiness, floorReached, statedNextRung, holds, narrative, sections, questions, footing, declarations, examples, design, decisions, implementedBy, referencedBy, verifiers }`, with `file` the member's carrier path; `sections` as the graph holds them, without `intent.openQuestions`; `questions` one row per open question in authored order, `{ index, question, blocking, key, address, line }` with its fields, `key` absent and `address` null when it has none; `footing` as dependency footing (recipe 21) reports it; `declarations` the keys of the member's pinned declarations in Design order; `design` the counts of entries, declarations, open questions and blocking questions; `decisions` the decisions its design columns name; `implementedBy` and `referencedBy` as `{ id, claim, file, line, role, component }`; and `verifiers` as `{ id, claim, enabled, file, line }`. `holds` lists the member's stated-next-rung failures as the reader returns them, each with its `clauseId` and `description`, and with `targets` on a typed-dependency failure. +- exampleRow: `{ id, member, file, statedReadiness, enabled, steps }` lists one example that refines or verifies the member, in id order, with `member` true and `steps` null when the example is itself a member of the bundle, its steps then sitting in its own member row. +- pointer: `{ line }` replaces a cut entry that has no entry address, `line` being the line the location table records for it, or null when it records none; the entry's Spec and file are those of the row that holds it, and its place in the bundle names its path. A cut entry whose text names other entry addresses becomes a list of them, led by its own address when it has one and otherwise ended by its pointer. - boundaryRows: a Pack scope's boundary is the Pack design's; a Spec-list scope's boundary is every Spec outside the list one authored relation away from a listed Spec, in the same row shape. -- fifthCut: The fifth cut reduces an addressable entry to its entry address and another entry to `{ spec, path, file, line }`, with `line` null when not located. Other entry addresses in removed text stay beside that pointer, and non-blocking questions retain their pointers and links; pinned declarations and blocking questions stay whole. -- cutEverywhere: every cut removes the affected text from each place it appears in the bundle, `sections` included, and blocking questions, pinned declarations and entry addresses stay whole in each place. -- budgetExceeded: `{ scope, budget, budgetExceeded: true, protectedLength, leftOut }` is the whole result when the protected content alone exceeds the budget, with `protectedLength` in the budget's unit. +- footingCut: `{ refines, dependsOn, constrainedBy, decidedBy }` replaces a member's footing at the footing cut, each a list of target ids in code-unit order, with a type that has no target left out. +- narrativeCut: The narrative cut replaces a member's narrative with the entry addresses written in it, in order of first appearance and joined by single spaces, or with null when it holds none. +- exampleCut: The example cut replaces each entry of a member's `behavior.examples`, and its `behavior.exampleSpace`, with its pointer, and each example row's `steps` that are not null with the pointer of that example's `behavior.examples`. +- questionCut: The question cut replaces each non-blocking question in `questions` with its address, or with its pointer when it has no key, and with `{ pointer, links }` when it has links, `pointer` being that address or pointer; blocking questions stay whole. +- sectionCut: The section cut replaces each section entry other than a pinned declaration with its entry address when it has one, and otherwise with its pointer. +- declarationCut: The declaration cut, taken only under `declarations: "addresses"`, replaces each pinned declaration in its member's Design section with its entry address, or with its pointer when its key has none; the member's `declarations` keeps the keys. +- leftOutLines: One fixed line per cut taken, in cut order, makes up `leftOut`: `Boundary details; ids kept per direction.`, `Footing details; ids kept per relation type.`, `Member narrative.`, `Example step text, including member sections and example-space vocabulary.`, `Non-blocking question text; addresses and links kept.`, `Section entry values other than pinned declarations; addresses and lines kept.` and `Pinned declaration text; entry addresses kept.` +- budgetExceeded: `{ scope, budget, declarations, budgetExceeded: true, protectedLength, leftOut }` is the whole result when the protected content alone exceeds the budget after every permitted cut, with `protectedLength` in the budget's unit. diff --git a/specs/consumers/engine-provenance.sdp.md b/specs/consumers/engine-provenance.sdp.md index c876569f..79a3abe9 100644 --- a/specs/consumers/engine-provenance.sdp.md +++ b/specs/consumers/engine-provenance.sdp.md @@ -18,4 +18,8 @@ relations: ## Behavior - rule: `sdp --version` prints one line, `sdp ()`, and exits 0. The version is the package's own version; the commit is the full hash the build was made from, or `unknown` when the build could not read one; a build from a checkout with uncommitted changes names the commit checked out when it ran. - rule: The build records the commit in a file beside the compiled CLI, and the CLI reads that file; it never runs git at run time. +- rule: The build reads the commit from git first. When git gives no full commit, it reads the archive's substituted commit file; a full commit there names the archived revision, and an absent, unsubstituted or malformed file leaves the commit `unknown`. - rule: Neither the version nor the commit enters the graph, a projection or a generated contract, so a derived artifact stays a function of the corpus alone. + +## Design +- archiveCommit: `.git-archive-commit` contains `$Format:%H$` in a checkout and is marked `export-subst` in `.gitattributes`, so git archives carry their full commit. The build reads the trimmed file only as a full Git object name, never as a command or an abbreviated hash. diff --git a/specs/consumers/graph-first-planning.sdp.md b/specs/consumers/graph-first-planning.sdp.md index f1e6870a..9286beea 100644 --- a/specs/consumers/graph-first-planning.sdp.md +++ b/specs/consumers/graph-first-planning.sdp.md @@ -20,12 +20,6 @@ relations: - outcome: Capture the intent of the next body of work as Specs below `ready`, so planning sessions derive backlog and readiness from the graph and plan files point to their carrying Specs. -### Open questions - -- [non-blocking #nextPacks] Which Packs group the next bodies of work once each is commissioned? - - decider: owner - - provisional: a focused corpus-checks Pack holds corpus rules, architecture constraints and planned architecture; coverage and run evidence join the Spec Studio Pack only if they join its design - ## Behavior - rule: `spec:consumers.delivery-session-on-ramp` owns per-session routing from graph state; this Spec owns how the next body of work's intent enters the corpus and is read back. diff --git a/specs/consumers/obsidian-vault.sdp.md b/specs/consumers/obsidian-vault.sdp.md index cebce97e..44d9b177 100644 --- a/specs/consumers/obsidian-vault.sdp.md +++ b/specs/consumers/obsidian-vault.sdp.md @@ -16,7 +16,7 @@ relations: --- # An Obsidian vault lets a person iterate on designs from the one graph -The Design Review links a Spec's relations both ways and gives keyed Pack questions a separate carrier-line link, but it offers no built-in filtering of a Pack or navigation from arbitrary prose mentions to their entries. The Spec Studio is designed for both a person who iterates on a design in the repository and a pull request reviewer who holds only a link. A linked-notes view would let a person follow relations, filter members by rung and design columns, open entries by address and see each pass's edits arrive; whether it is the Studio's local half is the question below. +The Design Review links a Spec's relations both ways and gives keyed Pack questions a separate carrier-line link, but it offers no built-in filtering of a Pack or navigation from arbitrary prose mentions to their entries. The Spec Studio is designed for both a person who iterates on a design in the repository and a pull request reviewer who holds only a link. A linked-notes view would let a person follow relations, filter members by rung and design columns, open entries by address and see each pass's edits arrive. It is a projection of its own, not the Studio's local half: the Studio's page set serves a reader who holds a link, the vault serves a person who iterates in a notes app, and each has its own verb and root. It waits for a reader who asks for it once the Studio's page set exists. Obsidian already does that for linked Markdown notes with properties: backlinks, a graph view, search, and filterable tables it calls Bases. The prototype in `explorations/obsidian-vault/` wrote notes from dumped graph and reader values, with relations and checked mentions as wikilinks, entry addresses as block links, and Bases for Pack members and standing queries. Pack members could be read as a sortable table. The prototype joined relation lists and computed flat properties itself, and its dump renamed `statedNextRungFailures` to `nextRungFailures`; those shortcuts are evidence for the product design, not the reader's contract. @@ -30,14 +30,13 @@ The principle is a linked-notes view regenerated from the one graph; Obsidian an - risk: A notes app invites editing, and an edit to a generated note is lost at the next regeneration without reaching the carrier. ### Open questions -- [blocking #studioRelation] Is the vault a projection of its own, or the Studio's local half beside the static page set a reviewer with only a link reads? - - decider: owner - - links: spec:consumers.spec-studio - - provisional: a projection of its own, written by its own verb under `generated/obsidian-vault/`, which the Studio's design pass may adopt as its local half - [non-blocking #otherNotesTools] Does the vault keep to the wikilinks and properties other notes tools read, or use Obsidian's Bases freely? + - decider: owner - provisional: notes and links stay plain Markdown with wikilinks any such tool reads, and the Bases are Obsidian's alone - [blocking #regenerationScope] Which files does the generator own, how are stale generated files removed, and where do Obsidian settings and a person's intent notes live so that regeneration and build invalidation never remove them? - decider: owner - provisional: the generator owns only its generated note folders and Bases; Obsidian settings and authored intent notes sit outside that owned set, and a completed regeneration removes stale generated notes + - trigger: a person asks for a vault beside the Studio's static page set ## Behavior +- rule: [#ownProjection] The vault is a projection of its own, written by its own verb under `generated/obsidian-vault/`, and no part of the Studio's page set. diff --git a/specs/consumers/spec-studio.components.sdp.md b/specs/consumers/spec-studio.components.sdp.md index 12b8ab92..69a44c06 100644 --- a/specs/consumers/spec-studio.components.sdp.md +++ b/specs/consumers/spec-studio.components.sdp.md @@ -2,7 +2,7 @@ id: spec:consumers.spec-studio.components kind: contract altitude: story -readiness: scoped +readiness: defined relations: refines: spec:consumers.spec-studio dependsOn: spec:consumers.spec-studio.data @@ -10,28 +10,24 @@ relations: --- # The Studio's custom elements each render one view in pages that load their script and supply graph data +The original's harness element has no subject in the graph, which holds example spaces, bound points and oracle bindings but no harness node, so its successor takes the Spec that owns an example space. No diagram renderer is chosen yet: the design that adds the first diagram names one within the custom-elements decision. + ## Intent - outcome: Give the Studio a small set of custom elements that each render one view of the graph for a Studio page or an HTML report that can load the element script and supply its graph data. -### Open questions -- [non-blocking #embeddedDiagrams] The original embeds interactive LikeC4 diagrams through an element that wraps LikeC4's own runtime; a LikeC4 export is designed-for and deferred, and the shipped Mermaid projection draws bounded one-hop Spec and Pack diagrams and never the whole graph. Which renderer draws a Studio diagram, and does that bound hold for it? - - decider: owner - - provisional: a later phase picks a renderer compatible with `spec:decisions.studio-web-components`, or a decision that refines it admits a framework runtime -- [non-blocking #harnessElement] The original's harness element takes a harness id and keeps its coverage current as a person works it; the graph holds example spaces, bound points and oracle bindings, and has no harness node and no `harness:` namespace. What does the element take as its subject? - - decider: owner - - provisional: a later-phase element takes `spec-id` naming the Spec that owns the example space and renders that space's dimensions, its children's bound points and its expected-outcome oracle; coverage stays a report, never a verifier run or a delivery fact ## Contract - Each element is self-contained and light on dependencies, and works in an HTML page that can load its script and supply its graph data. -- Each element reads the one data object its page sets, as `spec:consumers.spec-studio.data` states. An element with a graph subject takes that subject's graph id in an attribute named by its Design entry; an element over accumulated intents has no single graph subject, and a renderer-specific subject waits for its named design question. +- An element that renders a view of the graph reads the one data object its page sets, as `spec:consumers.spec-studio.data` states. An element with a graph subject takes that subject's graph id in an attribute named by its Design entry; an element over accumulated intents has no single graph subject. - Every element's name starts with `sdp-`. +- [#diagramRenderer] A diagram is drawn by a renderer that runs as a custom element under `spec:decisions.studio-web-components`, or by a framework runtime that a decision refining it admits; the design that adds the first diagram names the renderer and the bound on what one diagram draws. ## Design The elements in the original's order, with their attributes. - specCard: `sdp-spec-card` with `spec-id`, `variant` and `readiness-pill`: one Spec as a card, in full or short form, with its readiness pill shown or hidden. - traceGraph: `sdp-trace-graph` with `spec-id`, `depth` and `highlight`: the Spec's relations drawn as SVG to the given depth, highlighting what `highlight` names, such as Specs with no verifier binding. - maturityMap: `sdp-maturity-map` with `pack-id` and `group-by`: a Pack's members by readiness, grouped by the named field of the member values, such as kind or stated rung. -- harness: an interactive element over an example space; its name, subject attribute and coverage behavior await `spec:consumers.spec-studio.components#question.harnessElement`. -- diagramView: a diagram element whose renderer, name and subject attribute await `spec:consumers.spec-studio.components#question.embeddedDiagrams`. +- exampleSpace: `sdp-example-space` with `spec-id`, naming the Spec that owns the example space: the space's dimensions, its children's bound points and its expected-outcome oracle binding; its coverage is a report, never a verifier run or a delivery fact. +- diagramView: a diagram element whose name and subject attribute the design that adds it names, under the diagram-renderer rule. - scenarioEditor: `sdp-scenario-editor` with `spec-id` and `mode`: a given, when and then editor for one example, which in `propose` mode composes intent for a new one. - impactView: `sdp-impact-view` with `spec-id`: a Spec's impact panel on its own. - intentPanel: `sdp-intent-panel` with no graph-subject attribute, rendering the gathered intents across their scopes. diff --git a/specs/consumers/spec-studio.data.sdp.md b/specs/consumers/spec-studio.data.sdp.md index cf0d2cdd..6935ac26 100644 --- a/specs/consumers/spec-studio.data.sdp.md +++ b/specs/consumers/spec-studio.data.sdp.md @@ -2,7 +2,7 @@ id: spec:consumers.spec-studio.data kind: contract altitude: story -readiness: scoped +readiness: ready relations: refines: spec:consumers.spec-studio dependsOn: @@ -13,22 +13,22 @@ relations: - spec:extraction.entry-locations constrainedBy: spec:extraction.determinism --- -# The Studio hydrates from the serialized graph and the reader's values +# The Studio renders from the reader's values and embeds only what its script reads + +The build writes each value into the page's markup, so a page reads in full as HTML. Search is the exception: a browser refuses a fetch from a page opened through `file://`, so the search page carries its index inside it. On the first adopter's corpus that index is too large to repeat on every page, which is why search runs on a page of its own. ## Intent -- outcome: Give every Studio page its data as files the build writes beside it, so the page reads the graph the agent surface reads and derives nothing of its own. -### Open questions -- [non-blocking #aiSlices] The original hydrates the Studio from AI slices beside the graph and the validation report; the context bundle is a catalog recipe composed on each invocation and never saved, so no Spec states a saved slice. Does the Studio's data hold a slice, and which Spec would state it? - - decider: owner +- outcome: Render every Studio page from the values the reader returns for the graph the agent surface reads, so a page derives nothing of its own and carries only the data its script needs. ## Contract -- The Studio hydrates from data files under `generated/spec-studio/data/`, written by the build that writes its pages. -- Each data file holds values the graph serializes or the reader returns, and the page follows `spec:consumers.spec-studio`'s rule against recomputing graph joins, claims, delivery facts, readiness and floor failures. -- A page embeds the data it reads in one `script` element of type `application/json` with the id `sdp-graph`, parses it once into `window.__sdp__.graph`, and every element on the page reads from that one object. +- [#readerValues] The build renders the index from `specs()`, `packs()`, `findings()` and each Spec's design columns, a Pack page from `packContext` and the Pack totals, a Spec page from `specContext`, and the search page from the search index; every page's rail renders the rail values. A page states no value the reader did not return, apart from counts the build takes of those values. +- [#embeddedData] A page whose script reads data embeds it in one `script` element of type `application/json` with the id `sdp-data`, serialized as JSON with every `<` then written as the six characters `\u003c`, so no text can close the element. The search page embeds the search index there; every other page embeds no data, because its values are in its markup. +- [#noDataFiles] The page set writes no data file of its own: an agent reads the graph through `sdp q`, and `sdp build` writes the serialized graph. +- [#noSlices] The Studio's data holds no context bundle or other AI slice: the context bundle is composed on each invocation and never saved, and a design pass composes one with its recipe when it needs it. ## Design -- graphFile: the serialized graph exactly as `sdp build` writes `generated/graph.json`, with its `schemaVersion`. -- reportFile: the validation report, the findings the reader returns from `findings()`. -- specContexts: for each Spec, the reader's `specContext` value whole: descriptors, narrative, sections, `designColumns`, stated and derived readiness, `floorFailures`, the stated next rung with `statedNextRungFailures`, the floor's `nextRungFailures`, relations both ways with their reason and exercises when present, implementations, references, verifiers, the oracle binding when present, `entryLocations` and findings. -- packContexts: for each Pack, the reader's `packContext` value whole: framing, model references, members in authored order with each member's stated next rung, design columns and bindings, verifier gaps, boundary and findings. -- searchIndex: for each node, the fields the reader's `findByConcept` matches: id, title, anchor label, Pack framing, narrative and section content, so a search on the page matches what the concept entry matches. +- packTotals: `{ members: number; unresolved: number; heldBelowNextRung: number; waitingForAuthor: number; entries: number; declarations: number; openQuestions: number; blockingQuestions: number; withImplementations: number; withReferences: number; restsOn: number; restedOnBy: number; verifiedBy: number; supersededBy: number }`, the totals recipe 29 returns for the Pack, each counted as that recipe counts it. +- railValues: `{ packs: { id: string; title?: string; members: { id: string; title?: string; statedReadiness?: string; ownsExampleSpace: boolean }[] }[]; findings: { errors: number; warnings: number } }`, the values every page's rail renders: each Pack in id order with its members in authored order, a member owning an example space when its Spec context carries one, and the validation report's findings counted by severity. +- searchIndex: `{ records: SearchRecord[] }`, one record per graph node, in id order. +- searchRecord: `{ id: string; nodeType: "Primitive" | "Pack" | "Anchor" | "CodeNode"; page: string | null; title?: string; label?: string; framing?: string; narrative?: string; sections?: Record; file?: string; line?: number; specKind?: string; statedReadiness?: string; floorReached?: string | null; bindings: { edge: string; specId: string }[]; entries: SearchEntry[] }`: `title`, `label`, `framing`, `narrative` and `sections` exactly as the graph node holds them, the fields `findByConcept` reads; `page` the node's page path in the page set, null for an Anchor or a CodeNode; `file` and `line` as recorded; the kind and the two rungs on a Spec only, `floorReached` null when no rung derives; `bindings` the Specs an Anchor's or CodeNode's outgoing edges reach, with the edge type, and empty for any other node. +- searchEntry: `{ section: string; entry: string | null; key: string | null; address: string | null; line: number | null; text: string }`, one per value recipe 23's walk reaches in a Spec's narrative and sections, taken with no term: each string, and each value that is not a string under a key the author coins, as its JSON text; in that walk's order, with that recipe's entry path, coined key, address and line. A term matches an entry as that recipe matches it, a string by its key or text and any other value by its key only, so the entries a term matches are the rows recipe 23 returns for it. diff --git a/specs/consumers/spec-studio.index-page.sdp.md b/specs/consumers/spec-studio.index-page.sdp.md new file mode 100644 index 00000000..979cda8a --- /dev/null +++ b/specs/consumers/spec-studio.index-page.sdp.md @@ -0,0 +1,38 @@ +--- +id: spec:consumers.spec-studio.index-page +kind: behavior +altitude: story +readiness: ready +relations: + refines: + target: spec:consumers.spec-studio.lenses + reason: the index is where the Packs lens lands + dependsOn: + - spec:consumers.spec-studio.data + - spec:consumers.spec-studio.shell + - spec:consumers.derived-readiness-banner + - spec:consumers.binding-language-views +--- +# The index says where the corpus stands and narrows every Spec + +A reader of a large design needs two ways in: by the task they came to do and by a word they remember. The index gives the first and links to search for the second, opens with a summary the build counts, and replaces one long table with a list a person narrows. + +## Intent +- outcome: Give a person one landing page that says where the corpus stands, routes them by task, lists every Pack with its members, and narrows every Spec by the coordinates and bindings they ask about. + +## Behavior +- rule: [#summary] The page opens with a summary the build counts from reader values, each count in a sentence: the Specs and Packs; the Specs at each stated rung; the Specs stated above their floor; the open and blocking questions; the Specs with an implementation binding and with a verifier binding; and the findings by severity. +- rule: [#taskRoutes] A short list then routes by task, in fixed words that quote nothing from the corpus: read a Pack's design, find a Spec, entry or file by a word, narrow the Specs, and read the findings; each route links to its place in the page set. +- rule: [#packsLens] The Packs section lists every Pack in id order with its title and its members in authored order, each member with its stated rung beside its floor; it draws no capability tree. +- rule: [#specRows] The Specs section lists every Spec in id order, one row each: its title and id, kind and altitude, stated rung beside the floor reached, implementation binding and verifier binding as present or none, open and blocking questions, and its Packs. +- rule: [#filters] Filters narrow the Specs section by kind, altitude, stated rung, floor reached, implementation binding, verifier binding and Pack; each filter takes one value or any, the filters combine, and the section says how many of its rows it shows. Without script every row shows. +- rule: [#findings] The findings section lists the validation report's findings in its order, each with its severity in words, its validator id, its subject when it has one, linked to its page when it is a Spec or a Pack and as text otherwise, its message, and its recorded file and line as text. + +## UI +- summary: a short list of sentences under the page title, each with a count. +- taskRoutes: four links under a `Start here` heading: Read a Pack's design, Find a Spec, entry or file by a word, Narrow the Specs, Read the findings. +- packList: one closed disclosure per Pack, its summary line the Pack's title, id and member count, its body the members in authored order as links with their rung badges. +- specRow: one line per Spec: the title linked to its page, the id as code, the rung badge, then as plain text the kind, altitude, floor in words, the two binding values, the question counts and the Packs; the line wraps on a narrow screen. +- filterBar: one labelled select per filter above the Specs, each listing any and the values the rows hold, and a line saying how many of all rows show. +- sectionIds: the Packs, Specs and findings sections carry the element ids `packs`, `specs` and `findings`, which the top navigation, the rail and the task routes link to. +- findingsList: one line per finding, the severity word first. diff --git a/specs/consumers/spec-studio.intent-panel.sdp.md b/specs/consumers/spec-studio.intent-panel.sdp.md index 4187c6c3..92c3dd13 100644 --- a/specs/consumers/spec-studio.intent-panel.sdp.md +++ b/specs/consumers/spec-studio.intent-panel.sdp.md @@ -12,17 +12,10 @@ relations: --- # A composing panel on every page gathers scoped intent for an agent +The change-proposal contract requires the commit whose graph the person read, and a generated page set carries no commit, so a proposal's base comes from the build stamp when one is present and from the person otherwise. + ## Intent - outcome: Let a person gather the changes they want while they read, each bounded by a Spec, its neighbors, a Pack or one open question, and hand them to an agent that edits source. -### Open questions -- [non-blocking #proposalBase] The change-proposal contract requires the commit whose graph the person read, and a generated page set carries no commit; where does the panel get the proposal's base, and does it ask the person for a reason? - - decider: owner - - links: spec:consumers.intent-composition.proposal - - provisional: the base is the commit a present build stamp names, otherwise one the person supplies, and the person supplies one reason for the whole proposal; without a base and a reason the panel exports scoped intent as text, and the receiving agent writes the proposal against a known commit -- [non-blocking #packScope] The change-proposal contract scopes a change by Spec ids and entry addresses only, while the panel gathers intent bounded by a Pack or a Spec's neighbors; does a Pack or neighbors scope expand to its member or neighbor Spec ids at hand-off, or does a later proposal schema admit Pack scopes? - - decider: owner - - links: spec:consumers.intent-composition.proposal - - provisional: the ready proposal contract stands; a Pack-scoped request expands to the member Spec ids read from that Pack at the proposal base, and a request that changes Pack membership or framing, or names an empty Pack, stays scoped-intent text ## Behavior - rule: The composing panel is visible on every page. @@ -31,6 +24,8 @@ relations: - rule: Gathered intents persist in the browser's local storage, so refreshing a tab on a phone loses none. - 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: The panel hands its gathered intents off as one change proposal, on the change-proposal contract, which the person copies as text or saves as a document. 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: [#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 expands at hand-off to the member Spec ids that Pack holds at the proposal's base; a request that changes a Pack's membership or framing, or names an empty Pack, stays scoped-intent text, so the change-proposal contract keeps its Spec and entry scopes. ## UI - gatheredIntents: the intents gathered so far, headed 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. diff --git a/specs/consumers/spec-studio.lenses.sdp.md b/specs/consumers/spec-studio.lenses.sdp.md index 33de7949..898272aa 100644 --- a/specs/consumers/spec-studio.lenses.sdp.md +++ b/specs/consumers/spec-studio.lenses.sdp.md @@ -2,7 +2,7 @@ id: spec:consumers.spec-studio.lenses kind: behavior altitude: story -readiness: scoped +readiness: defined relations: refines: spec:consumers.spec-studio dependsOn: @@ -20,31 +20,26 @@ relations: --- # Four lenses read the graph as Packs, architecture, tests and evidence +A capability is read as a Pack, or as a projection over high-altitude behavior Specs that no view draws yet, so the Packs lens groups by Pack and draws no capability tree. A verifier binding states that a verifier exists, and pass and fail stay in CI, so the tests lens shows bindings and no run result. + ## Intent - outcome: Let a person see the graph four ways, by Pack, by architecture, by verification and by evidence, from one top navigation. ### Open questions -- [non-blocking #capabilityGrouping] A capability is a projection over high-altitude behavior Specs or a Pack grouping, and an arc's Pack is named for the capability it designs. Which grouping does the capability tree use, and which graph values supply it? - - decider: owner - - provisional: the Packs lens groups Specs by Pack, and no capability tree is drawn - [non-blocking #architectureVocabulary] The original's architecture lens draws runtime layers, ports and external systems beside components and their dependencies; `spec:decisions.architectural-annotation` gives a component anchor a layer and a bounded context and any code anchor a role, beside component membership and `uses`, and admits no port or external-system node. Does the lens group components by layer and context and draw only what those anchors hold? - - links: spec:consumers.structural-mermaid#question.layerAndContext -- [non-blocking #testRunResults] The original's tests lens shows each test's last run result; a verifier binding states that a verifier exists, and pass and fail stay in CI, as `spec:decisions.binding-not-liveness` rules. Does the lens show run results read from CI output, or bindings only? - decider: owner - - links: spec:observation.run-evidence - - provisional: The first Studio build shows bindings only; admitting run records in a later phase awaits an explicit input and reproducibility ruling. -- [non-blocking #ruleCoverage] The original's tests lens names the rules of a Spec that no test exercises; a rule may carry a key, an example's `verifies` may name the keys it exercises, and recipe 34 lists each keyed rule with its examples, while the graph cannot tell which examples exercise a rule with no key. Does the lens show keyed-rule coverage with the unkeyed rules counted apart, or name the uncovered Specs only? - - decider: owner - - links: spec:model.rule-keys - - provisional: the tests lens lists each keyed rule with the examples recipe 34 reports and counts the unkeyed rules apart + - links: spec:consumers.structural-mermaid#question.layerAndContext + - provisional: the lens groups components by their recorded layer and context and draws only component membership and `uses` edges, with no port or external-system node ## Behavior - rule: The Packs lens is the default landing page. +- rule: [#packGrouping] The Packs lens groups Specs by Pack and draws no capability tree. - rule: Clicking a component in the architecture lens opens its detail. +- rule: [#bindingsOnly] The tests lens shows verifier bindings and no run result; run records join it only through `spec:observation.run-evidence`, under an input and reproducibility ruling of their own. - rule: The evidence lens is read-only. ## UI The lenses in the order of the top navigation. -- packsLens: a Spec-by-Spec tree grouped by Pack, with each Pack's members in authored order; any capability grouping awaits `spec:consumers.spec-studio.lenses#question.capabilityGrouping`. -- architectureLens: the graph drawn as components and their dependencies, from component membership and `uses` edges, in an embedded interactive diagram whose renderer is the open question `spec:consumers.spec-studio.components#question.embeddedDiagrams`. -- testsLens: a coverage view: for each Spec, its verifiers with their bindings, and the rules no example exercises. +- packsLens: a Spec-by-Spec tree grouped by Pack, with each Pack's members in authored order, which the index page holds. +- architectureLens: the graph drawn as components and their dependencies, from component membership and `uses` edges, in an embedded interactive diagram whose renderer follows `spec:consumers.spec-studio.components#rule.diagramRenderer`. +- testsLens: a coverage view: for each Spec, its verifiers with their bindings, then each keyed rule with the examples recipe 34 reports as exercising it, with the unkeyed rules counted apart, because the graph cannot tell which examples exercise a rule with no key. - evidenceLens: the graph-recorded evidence fields and absence labels `spec:consumers.spec-studio.verification-panels` states; while the graph records no observation or other evidence, each reads not tracked. diff --git a/specs/consumers/spec-studio.pack-page.sdp.md b/specs/consumers/spec-studio.pack-page.sdp.md new file mode 100644 index 00000000..e9922bea --- /dev/null +++ b/specs/consumers/spec-studio.pack-page.sdp.md @@ -0,0 +1,52 @@ +--- +id: spec:consumers.spec-studio.pack-page +kind: behavior +altitude: story +readiness: ready +relations: + refines: spec:consumers.spec-studio + dependsOn: + - spec:consumers.spec-studio.data + - spec:consumers.spec-studio.shell + - target: spec:consumers.pack-design + reason: the page renders the Pack design the Pack context carries + - spec:consumers.derived-readiness-banner + - spec:consumers.binding-language-views + - spec:model.open-question-fields +--- +# A Pack page shows the Pack's design as one card per member + +The Markdown Pack page renders the Pack design as one wide member table, and the first adopter's reviewers read carrier files instead. This page renders the same assembly as a summary, then a card per member that a reviewer opens where they need the detail. + +## Intent +- outcome: Show where a Pack's design stands, member by member in reading order, on one page a reviewer scans quickly and opens where they need detail. + +### Open questions +- [non-blocking #summaryFormat] Should the summary state its counts as sentences or as a table? + - decider: owner + - provisional: Keep the counts as sentences. + +## Behavior +- rule: [#header] The page opens with the Pack's title, its id and its carrier file as text with a copy action, then the summary; the framing follows as prose under the heading Framing, with the vocabulary references, linked. +- rule: [#summary] The summary states each Pack total in one sentence that opens with its count: the members and the unresolved ones, the members held below their stated next rung and those waiting only for their author, the design entries and pinned declarations, the open and blocking questions, the members with an implementation binding and with a design reference, and the boundary in each direction. +- rule: [#holds] The summary's held sentence links each held member to its card with its count of unmet clauses, and its waiting sentence names the members whose stated next rung waits only for their author's statement unless every member does; the page never presents a rung as approved. +- rule: [#memberCards] The members follow in authored order, one closed card each. A card's summary line shows the member's position, title and kind, its stated rung beside the floor reached, its stated next rung with `nothing blocks` or how many of its clauses are unmet, its open and blocking questions, and its implementation and verifier bindings as present or none. +- rule: [#cardBody] An open card shows the member's id, linked to its Spec page, then the parts the next five rules state, in their order. +- rule: [#cardHolds] A card lists each clause unmet for the member's stated next rung, with the targets the clause names and their stated rungs. +- rule: [#cardDesign] A card counts the member's design entries and pinned declarations, and links the decisions it names, each with its stated rung. +- rule: [#cardQuestions] A card lists each open question with its blocking flag in words, its address when it is keyed, linked to the question on the Spec page, its recorded line, its text, and its decider, links, provisional reading and trigger when present. +- rule: [#cardCode] A card lists each unit that implements the member and each that references it, by id, label, and recorded file and line as text with a copy action, with its role and its component's id, layer and context. +- rule: [#cardVerifiers] A card states the member's verifier binding as present or none and, when it has any, counts its verifiers and verifying examples, each beside how many are enabled. +- rule: An unresolved member's card says only that the manifest names an id the graph does not hold. +- rule: [#boundary] The boundary follows in four closed sections, Rests on, Rested on by, Verified by and Superseded by, each headed with its count. Each row shows the outside Spec, linked, its stated rung, its implementation binding as present or none, and the members it joins under each relation type; an empty section reads None. +- rule: Then come the members with no verifier binding, those stated `ready` first, under one sentence saying the graph records bindings and never runs, so no page shows a run result; then the findings that name the Pack. +- rule: [#openAll] `Open all` and `Close all` open or close every card and section at once, and opening the page at a card's fragment opens that card. + +## UI +- headerBlock: the title as the page heading; below it the id and the carrier file, each with a copy action; then the summary; then the framing under a `Framing` heading, and the vocabulary references. +- summaryList: the totals as a short list of sentences under a `Where the design stands` heading, each opening with its count, a zero written as No. +- memberCard: a disclosure whose element id is the member's Spec id; its summary line reads `. `, then the kind, the rung badge, the floor in words, the next rung in words unless the member states ready, the question counts and the two binding values, all but the badge as plain text, wrapping onto further lines on a narrow screen. +- cardBody: in order, the id link, the unmet clauses, the design counts and decisions, the open questions, the code, and the verifier binding. +- questionItem: the blocking flag in words; for a keyed question, `question.<key>` as a link to its address with a copy action for the full address; `line <n>` when recorded; the text; then each present field on its own labelled line. +- unitItem: the unit id as code, its label, `<file>:<line>` with a copy action, its role, and its component with layer and context. +- boundaryTable: four columns, Spec, stated rung, implementation binding and joined members, the last listing each relation type with its members. diff --git a/specs/consumers/spec-studio.pack.sdp.md b/specs/consumers/spec-studio.pack.sdp.md index ec28d473..eb8d9193 100644 --- a/specs/consumers/spec-studio.pack.sdp.md +++ b/specs/consumers/spec-studio.pack.sdp.md @@ -4,13 +4,19 @@ specs: - spec:consumers.spec-studio - spec:decisions.studio-html-surface - spec:consumers.spec-studio.data + - spec:consumers.spec-studio.publisher - spec:consumers.spec-studio.shell + - spec:consumers.spec-studio.search - spec:consumers.spec-studio.lenses + - spec:consumers.spec-studio.index-page + - spec:consumers.spec-studio.pack-page - spec:consumers.spec-studio.spec-page + - spec:consumers.spec-studio.spec-page.core - spec:consumers.spec-studio.verification-panels - spec:consumers.spec-studio.intent-panel - spec:consumers.spec-studio.components - spec:decisions.studio-web-components + - spec:consumers.spec-studio.page-elements - spec:consumers.spec-studio.responsive - spec:consumers.spec-studio.sharing modelRefs: @@ -18,4 +24,4 @@ modelRefs: --- # Spec Studio -The Spec Studio of the original design, `docs/lineage/v0-design/07-spec-studio-and-projections.md` sections 1 to 3, 11 and 12, in the order a reviewer reads it: what the Studio is and why it renders HTML, the data it reads, its shell and lenses, the Spec page and its panels, the composing panel, its custom elements, its phone layout, and how a build is shared. Recipe 27, references into a design, shows each member's code bindings and the implemented Specs it builds on. +The Spec Studio of the original design, `docs/lineage/v0-design/07-spec-studio-and-projections.md` sections 1 to 3, 11 and 12, in the order a reviewer reads it: what the Studio is and why it renders HTML, the data it reads and the verb that publishes it, its shell and search, its lenses and the index, the Pack page, the Spec page with its core and its panels, the composing panel, its custom elements, its phone layout, and how a build is shared. The publisher, the shell, search, the index, the Pack page, the Spec page's core and the page elements make up the static page set; the other panels, the other lenses and the composing panel build on it. Recipe 27, references into a design, shows each member's code bindings and the implemented Specs it builds on. diff --git a/specs/consumers/spec-studio.page-elements.sdp.md b/specs/consumers/spec-studio.page-elements.sdp.md new file mode 100644 index 00000000..45a7919b --- /dev/null +++ b/specs/consumers/spec-studio.page-elements.sdp.md @@ -0,0 +1,33 @@ +--- +id: spec:consumers.spec-studio.page-elements +kind: contract +altitude: story +readiness: ready +relations: + refines: spec:consumers.spec-studio.components + dependsOn: + - spec:consumers.spec-studio.shell + - spec:consumers.spec-studio.search + decidedBy: spec:decisions.studio-web-components +--- +# A few custom elements filter, search, open and copy on a rendered page + +The build renders every page whole, so these elements add behavior to markup that is already there rather than rendering views of their own. A browser refuses a module script on a page opened through `file://`, so they ship as one classic script. + +## Intent +- outcome: Give the Studio's pages their few interactions through custom elements that enhance markup the build wrote, so a page with its script filters, searches and copies, and every page but the search page still reads in full without it. + +## Contract +- [#oneScript] The elements ship in one classic script, `assets/studio.js`, with no module syntax and nothing loaded at run time; the build bundles it from the package's source, with the concept matcher. +- [#enhanceOnly] Each element enhances markup the build wrote and renders no graph value of its own, except that the search results element renders values from the search index its page embeds. +- [#noRequest] No element requests anything on a page opened through `file://`; the stamp element requests `stamp.json` only over http or https. +- [#nativeControls] Every control an element adds is a native button, link, select or input with a text label, reachable from the keyboard in reading order. +- [#liveCounts] An element that changes what a page shows says so in words in a polite live region, such as how many rows a filter shows. + +## Design +- search: `sdp-search` with `page-root`, the relative path from its page to the page-set root: wraps the search field's form and, on submit, opens `<page-root>search.html#q=<term>` with the term percent-encoded. +- searchResults: `sdp-search-results`: on the search page, reads the index from the `sdp-data` element, matches the fragment's term and each term the person types, writes the results `spec:consumers.spec-studio.search` designs, and keeps the term in the fragment. +- filter: `sdp-filter`: wraps a hidden form of labelled `select` elements, each with `data-facet="<name>"` and an `any` option, and the rows marked `data-row`. On upgrade it shows the form, hides each row whose `data-<name>` differs from a chosen value, and writes `<shown> of <total> shown` in its `output` element; a row whose attribute holds several space-separated values, such as its Packs, matches when one of them equals the choice. +- expander: `sdp-expander`: adds `Open all` and `Close all` buttons for the `details` elements inside it, and on load and on each fragment change opens every `details` that holds the fragment's target and scrolls the target into view. +- copy: `sdp-copy` with `text`: a `Copy` button whose accessible name includes the text; it copies the text to the clipboard and says `Copied`, and where the clipboard is unavailable it selects the text so the person can copy it. +- stamp: `sdp-stamp` with `page-root`: served over http or https, it requests `<page-root>stamp.json` and, when it loads, shows its corpus commit, engine and build time as text; otherwise it shows nothing. diff --git a/specs/consumers/spec-studio.publisher.sdp.md b/specs/consumers/spec-studio.publisher.sdp.md new file mode 100644 index 00000000..7cdc4686 --- /dev/null +++ b/specs/consumers/spec-studio.publisher.sdp.md @@ -0,0 +1,46 @@ +--- +id: spec:consumers.spec-studio.publisher +kind: behavior +altitude: story +readiness: ready +relations: + refines: spec:consumers.spec-studio + dependsOn: + - spec:consumers.spec-studio.data + - target: spec:consumers.wholesale-view-rewrite + reason: the Studio's root is written, invalidated and certified the way the shipped roots are + constrainedBy: spec:extraction.determinism + decidedBy: + target: spec:decisions.shipped-projections-frozen + reason: the Studio publishes to a root of its own beside the four frozen ones +--- +# `sdp studio` publishes the Studio as one page set + +The Studio has its own verb and root beside the four shipped publishers, and is written the way they are. Its page set opens from the file system and uploads to a static host unchanged. The run adds no stamp, commit or time, so two runs over one graph write the same bytes, and a publishing step that wants build context writes its stamp beside the page set. + +## Intent +- outcome: Publish the Studio under its own root as one page set the graph alone determines, which opens through `file://` and uploads to a static host unchanged. + +## Behavior +- rule: [#verb] `sdp studio [root] [--exclude PATH]... [--check-clean]` validates the graph and writes the Studio's page set to `<root>/generated/spec-studio/`; `root` defaults to the working directory, and each exclusion is a root-relative path prefix, as for the shipped publishers. +- rule: [#ownRoot] A run writes nothing outside its root, writes no stamp, and leaves every carrier and source file under the root byte-identical. +- rule: [#wholesale] A run writes its page set to `generated/spec-studio.tmp/`, removes the prior root and renames the temporary into place, so no page of an earlier run survives a later one and no temporary survives a completed run. +- rule: [#buildInvalidates] Every command that enters the shared build, `sdp studio` among them, removes `generated/spec-studio/` and its temporary sibling before extraction, beside the four roots `spec:consumers.wholesale-view-rewrite#rule.buildInvalidates` names; a removal failure refuses the command before extraction. +- rule: [#checkClean] `sdp studio --check-clean` renders an independent twin and refuses divergent renders, then compares the current root with the new render; missing or drifted output returns nonzero and is removed, and clean output is replaced wholesale with byte-identical content. +- rule: [#findingsShown] Findings never withhold the page set: a run over a graph with validation findings still publishes and returns the validation exit code, and each finding shows on the index, and also on its subject's page when its subject is a Spec or a Pack. A run whose extraction fails publishes nothing and returns nonzero. +- rule: [#pageSet] The page set holds `index.html`, `search.html`, one `packs/<name>.html` per Pack, one `specs/<name>.html` per Spec, and `assets/studio.css` and `assets/studio.js`. `<name>` is the id after its namespace, with each uppercase ASCII letter written as `_` and its lowercase, so no two pages collide on a file system that ignores case. +- rule: [#deterministic] Two runs over one graph write byte-identical files, and no file holds a time, a commit, an absolute path or a host name. +- rule: [#linksResolve] Every link a page writes stays inside the page set: it resolves to a page the run wrote and, with a fragment, to an element id that page carries, apart from the search page's term fragment `search.html#q=<term>`, which names no element. No page links a carrier or source file, an editor URI or an external address. + +## Example space +```gwt-vocabulary +Given an extraction root holding {corpus:"a Pack and its member Specs"|"a Spec with a validation error"|"a carrier the extractor refuses"} and a stale Studio page {stalePage:string} +When the studio command runs twice at that root, with check-clean {checkClean:boolean} +Then each run exits {exitCode:number} +Then the page set holds the entry page {entryPage:string} +Then the two runs wrote byte-identical files: {byteIdentical:boolean} +Then the stale page survives: {staleSurvives:boolean} +Then a temporary Studio sibling survives: {temporarySurvives:boolean} +Then every carrier and source file under the root stays byte-identical: {rootUntouched:boolean} +Then every link a page writes resolves inside the page set: {linksResolve:boolean} +``` diff --git a/specs/consumers/spec-studio.responsive.sdp.md b/specs/consumers/spec-studio.responsive.sdp.md index 5b787cb3..ccb70a89 100644 --- a/specs/consumers/spec-studio.responsive.sdp.md +++ b/specs/consumers/spec-studio.responsive.sdp.md @@ -2,31 +2,26 @@ id: spec:consumers.spec-studio.responsive kind: behavior altitude: story -readiness: scoped +readiness: defined relations: refines: spec:consumers.spec-studio dependsOn: spec:extraction.claim-taxonomy --- # The Studio reads on a phone as on a desktop +The curated graph's edges carry no confidence, and the impact graph's inferred imports stay outside it, so a compact diagram shortens by distance alone. + ## Intent - actor: A person reviewing a design on a phone, including a pull request reviewer who holds only a link. - outcome: Let a design be reviewed on a phone through the same pages a desktop shows. -### Open questions -- [non-blocking #summaryMode] The curated graph's edges carry no confidence, and the impact graph's file-level inferred imports stay outside it; which details does a compact diagram presentation hide? - - decider: owner - - provisional: a compact diagram drops relations past one hop -- [non-blocking #phoneBreakpoint] Which exact CSS viewport width switches the layout to one column? - - decider: owner - - provisional: 720 CSS pixels ## Behavior - rule: Every page of the Studio is responsive, so the same pages serve a desktop and a phone. ## UI -- singleColumn: every layout collapses to one column below the breakpoint settled by `spec:consumers.spec-studio.responsive#question.phoneBreakpoint`. +- singleColumn: every layout collapses to one column below 720 CSS pixels of viewport width. - pinchZoom: on a touch device, every diagram zooms with a pinch. -- summaryMode: a compact diagram presentation for narrow screens, whose contents await `spec:consumers.spec-studio.responsive#question.summaryMode`; it assigns no confidence to an edge. +- summaryMode: a compact diagram presentation for narrow screens that drops relations past one hop and assigns no confidence to an edge. ## Example space ```gwt-vocabulary diff --git a/specs/consumers/spec-studio.sdp.md b/specs/consumers/spec-studio.sdp.md index 9799ea2c..cb072512 100644 --- a/specs/consumers/spec-studio.sdp.md +++ b/specs/consumers/spec-studio.sdp.md @@ -21,41 +21,18 @@ relations: - actor: A person in the repository who designs a capability ahead of its code and iterates on it in passes, and a pull request reviewer who holds only a link; both read far more than they change. - problem: The generated Markdown Design Review offers no built-in search or filters, and navigation is through its links, so reviewers in the first adopter open carrier files alongside it. - outcome: Give a person reviewing a design one generated HTML workbench that explores the whole graph through lenses, shows each Spec and Pack in context, and turns a wanted change into scoped intent rather than an edit. -### Open questions -- [non-blocking #phaseZeroSplit] No Spec states phase 0's deliverables: the index, the Pack page, search and the `sdp studio` publisher with its flags, output root, invalidation, clean check and exit code. Which child Specs carry them, and do pull request previews join phase 0? - - decider: owner - - provisional: child Specs for the index, the Pack page, search and the publisher; the publisher's draft: `sdp studio [root] [--exclude PATH]... [--check-clean]` writes only `generated/spec-studio/` under the root, which defaults to the working directory, with each exclusion a root-relative path prefix; a run writes its page set to `generated/spec-studio.tmp/` and replaces the live root with it wholesale; every `sdp build` attempt invalidates the live and temporary Studio roots before extraction, and an unsuccessful Studio run removes any Studio root it cannot certify; under `--check-clean` it compares an independent twin with the current output, returns nonzero and removes divergent, missing or drifted output, and replaces certified output wholesale; it shows validation findings without withholding publication and returns the validation exit code; previews stay a later phase -- [non-blocking #pageModel] The Studio is built as a single-page application, while its data contract both writes data files, which a page opened through `file://` cannot fetch, and embeds each page's data; is the Studio one page or a static page set, and how does each page get its data with no request? - - decider: owner - - links: spec:consumers.spec-studio.data - - provisional: a static page set of an index page and one page per Pack and per Spec, each embedding the data it reads in one JSON script element, with its local assets; a standalone data file, if any, duplicates the embedded values for consumers outside the page -- [non-blocking #recipeValues] The Studio shows only values the reader computes, while its pages need answers only catalog recipes compute, such as pinned declarations and keyed-rule coverage; may the Studio's build compose recipe answers over the same graph, and which? - - decider: owner - - links: spec:consumers.spec-studio.data - - provisional: the build composes a recipe's answer over the same graph only when the Studio's design names that recipe, as recipe 24's pinned declarations and recipe 34's keyed-rule coverage; the page recomputes no join, and no context bundle is saved as Studio data -- [non-blocking #acceptanceMeasure] The first build's acceptance names one adopter's corpus, a register id and a path in another repository; does phase 0 keep those five named tasks, or state a measure any corpus can run? - - decider: owner - - provisional: keep the first adopter's five named tasks until phase 0 is accepted ## Behavior - rule: The read-only Studio ships in the open-source package `@libar-dev/software-delivery-protocol`, beside the four shipped projections; hosted review, shared comments and hosted previews may later ship under `@libar-ai/`. - rule: The Studio is a projection of the one graph: its page set under `generated/spec-studio/` is regenerated from the graph, states no truth, and confers nothing back into the graph. -- rule: The Studio is a mostly static single-page application, buildable as an `index.html` page and its assets, and it needs no server. +- rule: [#staticPageSet] The Studio is a static page set: an index page, a search page, and one page per Pack and per Spec, with their local assets. Its build renders each page whole, so a page needs no server, and every page but the search page reads without its script, which adds filtering, search and copying. - rule: The Studio works offline: the same files open from the file system through `file://` for local use and from any static host for sharing. - rule: The Studio is reproducible: one graph renders byte-identical files, and an asset whose filename embeds a hash of its content gets the same name from the same graph. - rule: The Studio is read-rich: everything the graph holds about a Spec or a Pack is explorable in it. - rule: The Studio is write-careful: it writes nothing to canonical source, and a change a person wants leaves it as scoped intent for an agent, as `spec:consumers.edit-model` states. - rule: [#noSecondGraph] The Studio never builds a second graph: every join, claim, delivery fact, derived readiness, floor failure and finding it shows is a value the reader computed when the Studio was built, and the page computes none of them again. +- rule: [#recipeAnswers] A page shows a catalog recipe's answer only when the Studio's design names that recipe, and the build composes the answer over the graph it derived; the page recomputes none of it. - rule: The Studio stands beside the shipped Design Review, census, Mermaid and Gherkin projections and re-specifies none of them, which `spec:decisions.shipped-projections-frozen` keeps as they are. -## Example space -```gwt-vocabulary -Given an extraction root whose graph holds a Pack and its member Specs -When the Studio renders twice from freshly derived graphs of that root -Then the page set holds the entry page {entryPage:string} -Then the two renders are byte-identical: {byteIdentical:boolean} -Then every carrier and source file under the root stays byte-identical: {rootUntouched:boolean} -``` - ## Verification — manual -- The Studio's first build is accepted when a person completes, from the Studio's page set and the files it links to, the five tasks the first adopter set over its history-view Pack (`libar-platform/docs/feedback/sdp-feedback-01.md`): find the history contract, locate OD-157, explain the aggregate dependency, find one declaration's type comparison, and say whether a paused-rebuild example has native-backend evidence. The first build reads no run evidence, so the last task passes when the person finds that the example requires the native backend tier and has a bound verifier, and reports that no run result is supplied. +- The Studio's first build is accepted when a person completes, from the Studio's page set and the files it links to, the five tasks the first adopter set over its history-view Pack (`libar-platform/docs/feedback/sdp-feedback-01.md`): find the history contract, locate OD-157, explain the aggregate dependency, find one declaration's type comparison, and say whether a paused-rebuild example has native-backend evidence. The first build reads no run evidence, so the last task passes when the person finds that the example requires the native backend tier, reads whether a verifier is bound to it, and reports that no run result is supplied. diff --git a/specs/consumers/spec-studio.search.sdp.md b/specs/consumers/spec-studio.search.sdp.md new file mode 100644 index 00000000..c7abab19 --- /dev/null +++ b/specs/consumers/spec-studio.search.sdp.md @@ -0,0 +1,36 @@ +--- +id: spec:consumers.spec-studio.search +kind: behavior +altitude: story +readiness: ready +relations: + refines: spec:consumers.spec-studio.shell + dependsOn: + - spec:consumers.spec-studio.data + - target: spec:consumers.reader + reason: the page matches with the reader's concept matching +--- +# Search finds a Spec, a Pack, an entry or a bound file by a word + +The first adopter asked to search entry keys, pinned declarations and file paths. A Spec's envelope holds no tags, so search reads what the graph holds: the fields concept search reads, which already include every pinned declaration, plus the keys authors coin and the files the graph records. The page matches with the reader's own code, so a term finds on the page what `findByConcept` finds through `sdp q`. + +## Intent +- outcome: Let a person find a Spec, a Pack, a keyed entry or the code bound at a file by a word they remember of it, from any page and with no request. + +## Behavior +- rule: [#oneMatcher] Search matches with the reader's concept matching, one implementation the package exports without Node dependencies and the page script bundles, so over the fields `findByConcept` reads, the page returns the nodes it returns, in its order. +- rule: [#addedFields] Search also matches the keys authors coin, Design and UI keys, open question keys and rule keys, and a node's recorded file path; a node matched only through these follows every node `findByConcept` matches, in id order. It indexes nothing else. +- rule: A term matches where it appears in a field, without regard to case, as `findByConcept` matches; an empty term matches nothing. +- rule: [#searchPage] The search field on any page opens the search page with the term in its fragment, `search.html#q=<term>`; the search page matches as the person types and keeps the term in its fragment, so a search is a link. +- rule: [#resultShape] Each result names its node type in words. A Spec result shows its title, id, kind, and stated rung beside the floor reached, and opens its page; a Pack result shows its title and id and opens its page, with no readiness; an Anchor or CodeNode result shows its id, label, and recorded file and line as text with a copy action, and links each Spec its edges reach, with the edge type. +- rule: [#entryHits] A Spec with a matching entry is a result. Under a Spec result, the page lists the Spec's entries the term matches, as `spec:consumers.spec-studio.data#design.searchEntry` states, in the index's order, each with its section, key and recorded line, linked to the entry's element id on the Spec page when it has an entry address, and otherwise to its section's element id, or to the narrative's. +- rule: The page shows fifty results at a time, with a control that shows the next fifty. +- rule: The search page reads only the index it embeds; it fetches nothing and joins nothing. + +## UI +- searchField: a labelled text field and a Search button in the top navigation of every page. +- resultsHeader: the term, how many nodes and entries match, and the fields searched. +- resultCard: one card per node, in result order: its node type in words, title and id, the fields that matched, and the Spec's rungs or the unit's location as the result rule states; then its entry hits. +- entryHit: one line per entry: its section, its key as code, its recorded line, and the text around the term, shortened. +- noMatch: `No node matches` and the term, then the fields searched. +- needsScript: without script, the search page says that search needs it and links to the index. diff --git a/specs/consumers/spec-studio.sharing.sdp.md b/specs/consumers/spec-studio.sharing.sdp.md index 4bc99c9a..4fe74a4c 100644 --- a/specs/consumers/spec-studio.sharing.sdp.md +++ b/specs/consumers/spec-studio.sharing.sdp.md @@ -14,18 +14,14 @@ relations: ## Intent - outcome: Let a stakeholder open the Studio of a pull request from a link, and let its author know which graph and which build the stakeholder read. -### Open questions -- [non-blocking #stampLoading] How does the shell read the publishing stamp while generated pages stay a function of the graph and a page opened through `file://` makes no request, which keys does the stamp hold, and which step publishes a preview? - - decider: owner - - links: spec:consumers.spec-studio#question.phaseZeroSplit - - provisional: the shell reads `stamp.json` only when served over http or https, so a `file://` page makes no stamp request; its keys are `schemaVersion`, `corpusCommit`, `engine` and `builtAt`; publishing a preview and writing its stamp are the adopter's step, and the Studio build writes only its graph-derived page set ## Behavior - rule: A build is shared by uploading its page set to a static host, such as S3, Vercel or GitHub Pages. - rule: Each pull request can publish its Studio as a preview. - rule: A stakeholder opens the link, navigates the Studio and composes intent, and the pull request's author hands that intent to an agent that edits the source. - rule: The Studio's build writes no stamp, so every page and data file it writes stays a function of the graph alone. The publishing step that uploads a build writes one stamp file beside the page set, and the shell shows it as build context when it is present. +- rule: [#publishingStep] Publishing a preview and writing its stamp are a step the adopter runs, never the Studio's build; the shell reads the stamp as `spec:consumers.spec-studio.shell#rule.stamp` states. ## Design -- buildStamp: `stamp.json` beside the page set, written by the publishing step and never by the Studio's build, holding the graph's `schemaVersion`, the git commit the build came from or `unknown` for a local build, the engine `sdp --version` names, and the build time. +- buildStamp: `{ schemaVersion: string; corpusCommit: string; engine: string; builtAt: string }` in `stamp.json` beside the page set, written by the publishing step and never by the Studio's build: the graph's `schemaVersion`, the git commit the build came from or `unknown` for a local build, the engine `sdp --version` names, and the build time as an ISO 8601 UTC timestamp. - previewPath: a pull request's preview is served at `<host>/preview/<pr-number>/`. diff --git a/specs/consumers/spec-studio.shell.sdp.md b/specs/consumers/spec-studio.shell.sdp.md index 6ea97366..df05ecae 100644 --- a/specs/consumers/spec-studio.shell.sdp.md +++ b/specs/consumers/spec-studio.shell.sdp.md @@ -2,37 +2,44 @@ id: spec:consumers.spec-studio.shell kind: behavior altitude: story -readiness: scoped +readiness: ready relations: refines: spec:consumers.spec-studio dependsOn: - spec:consumers.spec-studio.data - target: spec:consumers.spec-studio.lenses reason: the top navigation opens each lens + - target: spec:consumers.spec-studio.responsive + reason: the rail moves below the canvas under the phone breakpoint + - target: spec:validation.prose-mentions + reason: a mention links where the validator resolves it + - target: spec:consumers.spec-studio.sharing + reason: the shell shows the stamp a publishing step writes beside the page set --- # The Studio's shell frames every page with navigation, a rail and a canvas +The shell carries what every page shares, including the rules that keep a page readable: by keyboard, without color, on a phone and without its script. Recent edits stay out of it, because a page is a function of the graph alone and git holds the history. + ## Intent -- outcome: Let a person move between lenses, Packs and Specs from any page, and find a Spec by what they remember of it. -### Open questions -- [non-blocking #railHarnesses] The original's left rail shows the current Pack's tree, its specs and then its harnesses; the graph has no harness node. Does the rail list harnesses, and from which graph value? - - decider: owner - - provisional: the rail lists the current Pack's members that own an example space, each linked to its Spec page, and creates no harness identity -- [non-blocking #recentEdits] The original's left rail lists recent edits; the graph carries only current state, git is the event log, and the shipped views are a function of the graph alone. Does the rail read recent edits from git when the Studio is built, or leave them out? - - decider: owner - - provisional: leave recent edits out, so a Studio page stays a function of the graph alone -- [non-blocking #searchTags] The original's search indexes tags; the Spec envelope is closed to `id`, `kind`, `altitude`, `readiness` and `relations` with the H1 as title, and no free-form tag vocabulary is admitted. What, if anything, does search index in place of tags? - - decider: owner - - provisional: search matches what `findByConcept` matches, and indexing any further value needs the phase-0 search Spec's own contract +- outcome: Let a person move between the index, Packs and Specs from any page, find a Spec by what they remember of it, and read every page by keyboard, on a phone and without its script. ## Behavior -- rule: Every page has the same shell: a top navigation, a left rail and a main canvas. -- rule: Search is persistent on every page and uses the searchIndex data entry's scope; each result identifies its node type. A Spec result shows its stated rung beside the floor reached and opens its page; a Pack result opens its page without a readiness label; an Anchor or CodeNode result shows its recorded source location. +- rule: Every page has the same shell: a top navigation, a left rail and a main canvas, and it opens with a link that skips to the canvas. +- rule: A search field is on every page; `spec:consumers.spec-studio.search` states what it matches and how its results show. - rule: The rail's count of findings is the count of the validation report's findings, never a count the page derives. +- rule: [#noRecentEdits] The shell shows no recent edits and no other history: git holds it. +- rule: [#stamp] Served over http or https, the shell reads `stamp.json` beside the page set and, when it loads, shows the build context it names; a page opened through `file://` requests nothing and shows no stamp. +- rule: [#rungBadge] A stated rung shows as a badge that carries the rung's word, colored from a palette of four that stays distinct under color blindness; the floor reached shows beside it as words with no color of its own, and only the divergence banner adds a warning color, beside its words. +- rule: [#wordsBesideColor] Every status a color marks is also written in words, so no reading depends on color. +- rule: [#keyboard] Every link and control is reachable from the keyboard in reading order and shows where the focus is. +- rule: [#lineLength] Prose runs no wider than about 80 characters, and a table wider than the canvas scrolls inside its own box, never the page. +- rule: [#text] Authored text renders as plain text with each inline code span as code, and no other Markdown is read. A mention the prose-mentions validator reads links to its target's page when the target resolves, and to the entry when the mention is an entry address. +- rule: [#withoutScript] The index, every Pack page and every Spec page read in full without their script: their collapsible parts are native disclosure elements, and only filtering, copying and the stamp need the script. The search page needs its script to match, and without it says so and links to the index. ## UI The parts of the shell, top to bottom and left to right. -- topNavigation: Packs, Architecture, Tests, Evidence and Search; a capability grouping awaits `spec:consumers.spec-studio.lenses#question.capabilityGrouping`. -- leftRail: the current Pack's tree, its member Specs in authored order and the validation report's finding counts by severity; recent edits await `spec:consumers.spec-studio.shell#question.recentEdits`. +- topNavigation: the Studio's name linking to the index, then Packs, linking to the index's Packs section, then the search field; each lens page the page set holds adds its entry in the order `spec:consumers.spec-studio.lenses` gives. +- stampLine: at the end of the top navigation, the stamp's corpus commit, engine and build time, when the stamp loads. +- leftRail: on a Pack page, the Pack's members in authored order, each linked to its card on the page; on a Spec page, each Pack the Spec belongs to with its members in authored order, each linked to its Spec page and the current one marked; on any other page, every Pack; then, on a Pack or Spec page, under an `Example spaces` heading shown only when there is one, each member of the rail's Packs that owns an example space, linked to its Spec page, with no harness named; then the validation report's finding counts by severity, linked to the index's findings. +- railOnPhone: below the phone breakpoint the rail follows the canvas, so a page opens on its content. - mainCanvas: the page of the Spec or Pack selected in the rail or in a lens. -- search: matches the fields `spec:consumers.spec-studio.data#design.searchIndex` lists, with the same matching and ordering as `findByConcept`; results follow the search rule. diff --git a/specs/consumers/spec-studio.spec-page.core.sdp.md b/specs/consumers/spec-studio.spec-page.core.sdp.md new file mode 100644 index 00000000..1674ba0e --- /dev/null +++ b/specs/consumers/spec-studio.spec-page.core.sdp.md @@ -0,0 +1,45 @@ +--- +id: spec:consumers.spec-studio.spec-page.core +kind: behavior +altitude: story +readiness: ready +relations: + refines: spec:consumers.spec-studio.spec-page + dependsOn: + - spec:consumers.spec-studio.data + - spec:consumers.spec-studio.shell + - spec:consumers.derived-readiness-banner + - spec:consumers.binding-language-views + - spec:model.open-question-fields + - spec:model.rule-keys + - spec:model.relation-reasons + - spec:extraction.entry-locations + decidedBy: spec:decisions.authored-entry-order +--- +# A Spec page's core shows the whole Spec, with an anchor for every entry + +The full Spec page adds panels to this core: diagrams, a runtime neighborhood, verification and evidence panels, impact, and actions that compose intent. The core shows what the Spec context holds, as text, so a reviewer with only a link reads the whole Spec and can cite any entry by its address. + +## Intent +- outcome: Show everything the graph holds about one Spec on one page that reads without script: its header, its sections in order with an anchor for every addressable entry, its relations both ways, its bindings and its findings. + +## Behavior +- rule: [#header] The header shows the title; the id with a copy action; the kind's display label and the altitude; the stated rung beside the floor reached on one line, with the divergence banner only in the dishonest direction; the stated next rung with `nothing blocks` or each unmet clause and its targets; the Packs the Spec belongs to, linked; its design columns; its findings by severity; and its carrier file as text with a copy action. It shows no owner, capability, tag or last-verified time: the envelope holds none, the Packs stand for a capability, and a binding never records a run. +- rule: [#summary] A summary in words follows the header with what the header does not show: the open and blocking questions, and the implementation, verifier and oracle bindings as present or none. It repeats no rung the header shows. +- rule: [#sections] The narrative comes next, then each section the Spec carries as a panel open by default, in the order intent, behavior, constraints, model, design, decision, verification, ui, with its entries in authored order. A section the Spec lacks is absent, and the page adds no section the graph does not hold. The narrative carries the element id `narrative` and each section panel `section.<name>`, such as `section.behavior`. +- rule: [#entryIds] Every entry an entry address can name, a keyed Design or UI entry, a keyed rule or flow and a keyed open question, carries an element id equal to its address after the `#`, such as `design.<key>`, and shows its key and its recorded line. +- rule: [#questions] Each open question shows its blocking flag in words, its key as code with a copy action for the full entry address when it has one, its text, its decider, its links, linked, its provisional reading and its trigger when present, and its recorded line. It shows no date: git records when a line changed. +- rule: A `gwt` or `gwt-vocabulary` fence shows as preformatted step text, as authored. +- rule: [#relations] Relations show both ways, each with its type, its direction, its claim, the other endpoint, linked when it is a Spec or a Pack, whether it resolves, and its reason and the rule keys it exercises when present. +- rule: [#bindings] Bindings speak binding language. The implementation binding, design reference, verifier binding and expected-outcome oracle each read present or none, and list each unit, test anchor, example or oracle by id, label, and recorded file and line as text with a copy action; a unit adds its role and component, and an example verifier says whether it is enabled. Runtime observation reads not tracked, and run results read not recorded in the graph. +- rule: [#findings] The findings that name the Spec close the page, each with its severity in words, its validator id and its message. + +## UI +- headerBlock: the title as the page heading; below it a line with the id, kind, altitude, the rung badge with the floor in words, and the next rung; then the Packs, the design columns, the finding counts and the carrier file. +- summaryLine: one sentence under the header. +- sectionPanel: an open disclosure per section, headed with the section's name, and for Verification its mode. +- entryRow: an entry's key as code, its value, and `line <n>` when recorded, in a definition list. +- questionItem: the blocking flag in words, `question.<key>` as code when keyed, a copy action whose text and accessible name carry the full entry address, with no second code span of that address, the text, each present field on its own labelled line, and `line <n>` when recorded. +- relationsTable: five columns, relation and direction, other end, claim, reason, and exercised keys, with an unresolved endpoint marked in words. +- bindingsBlock: four labelled lines, each present or none with its items below it, then runtime observation and run results. +- findingsList: one line per finding, the severity word first. diff --git a/specs/consumers/spec-studio.spec-page.sdp.md b/specs/consumers/spec-studio.spec-page.sdp.md index 4c288192..31fb8557 100644 --- a/specs/consumers/spec-studio.spec-page.sdp.md +++ b/specs/consumers/spec-studio.spec-page.sdp.md @@ -2,7 +2,7 @@ id: spec:consumers.spec-studio.spec-page kind: behavior altitude: story -readiness: scoped +readiness: defined relations: refines: spec:consumers.spec-studio dependsOn: @@ -21,44 +21,27 @@ relations: The original runtime panel linked Fastify routes to Effect programs, showed what Effect layers provided and required, and recorded Awilix registrations and their lifetimes, beside ports and external systems. That representation is lineage evidence in `docs/lineage/v0-design/07-spec-studio-and-projections.md`, section 2.3, Section F. The current structural anchors record identity, component membership, uses, role, and a component's architectural layer and context; the question below asks whether a later runtime panel needs more. +The page shows only what the graph holds. The original header's owner, tags and last-verified time, its schema bindings and its `UI / Stories` section have no graph value behind them, so the page shows none of them. + ## Intent - outcome: Show everything the graph holds about one Spec on one page, in panels a person opens and closes one at a time. ### Open questions -- [non-blocking #headerFields] The original header shows an owner, a capability, tags and a last-verified time; the envelope is closed to `id`, `kind`, `altitude`, `readiness` and `relations` with the H1 as title, a capability is a projection or a Pack, and a verifier binding never records when a test last ran. Which of the four does the header show, and from which graph value? - - decider: owner -- [non-blocking #rungColors] The original pins one color for each of seven rungs: light grey for `sketch`, blue for `framed`, cyan for `specified`, green for `designed`, emerald for `bound`, indigo for `executable` and violet for `verified`; readiness has four rungs, idea through ready, and the floor reached renders beside the stated rung. Does the palette color the stated rung, the floor reached, or both? - - decider: owner -- [non-blocking #questionMetadata] The original shows each open question's owner and the date it was added; an open question carries its text, whether it blocks, an optional key and the optional fields `decider`, `links`, `provisional` and `trigger`, where `decider` names who settles it as a corpus token rather than a person, and git, not the graph, records when its line was added. Which fields does the panel show, and does a date stay off a page that is a function of the graph? - - decider: owner - - links: spec:model.open-question-fields - - provisional: the panel shows every field the graph supplies, as the intent panel states, and no date - [non-blocking #runtimeComposition] Does the runtime panel show more than the structural neighborhood of the Spec's implementation anchors? -- [non-blocking #schemaBindings] The original's bindings panel lists schema bindings beside code and test bindings; anchors bind implementation code, tests and oracles, and no anchor binds a schema. Does a schema binding need a home? -- [non-blocking #uiStories] The original's page outline names a section `UI / Stories`, whose UI half the UI panel carries; the original never says what a story is. What is a story, and does the page show one? -- [blocking #sourceLinks] Panels name code units, components and anchors, and the architecture lens selects a component, yet no Studio page or data value designs a destination for them. Does each get a page, or show its id and recorded repository-relative file and line as text? - decider: owner - - provisional: no code-unit pages in phase 0; a code unit or anchor shows its id and recorded repository-relative file and line as text, with an action that copies file:line and no editor URI, absolute path or assumed source URL; source navigation waits for a separately designed source target, and a component's attributes, members and uses show in the architecture lens + - provisional: the panel shows only the structural neighborhood the anchors record, until a reader asks for more of the runtime composition ## Behavior - rule: Each panel collapses and expands on its own. -- rule: The header renders readiness as `spec:consumers.derived-readiness-banner` states: the stated rung beside the floor reached, with the divergence banner only in the dishonest direction. -- rule: The bindings panel speaks binding language as `spec:consumers.binding-language-views` states: each binding present or none, and the runtime observation not tracked. -- rule: Every Spec and Pack a panel names links to its page; how a code unit or anchor is reached awaits `spec:consumers.spec-studio.spec-page#question.sourceLinks`. -- rule: The design panel lists the `design` entries in authored order with their keys as code, and the UI panel lists the `ui` entries the same way, as `spec:decisions.authored-entry-order` rules for the Design Review. +- rule: The page's core, its header, sections, relations, bindings and findings, is the one `spec:consumers.spec-studio.spec-page.core` states, and the panels below add to it. +- rule: [#sourceText] Every Spec and Pack a panel names links to its page. A code unit, component or anchor shows its id and its recorded repository-relative file and line as text, with an action that copies `file:line`; the page set holds no code-unit page and writes no editor URI, absolute path or assumed source URL, source navigation waits for a source target designed for it, and a component's attributes, members and uses show in the architecture lens. ## UI -The panels in page order. -- header: the Spec's id, its stated readiness beside the floor reached, its stated next rung with the clauses that keep it below that rung, its altitude and kind; its title; its design columns; the Packs it belongs to; and its warning and error counts as pill badges; the header infers no promotion. -- readinessColors: a palette distinct under color blindness, with text beside every color; whether it colors the stated rung, floor reached or both awaits `spec:consumers.spec-studio.spec-page#question.rungColors`. -- intentPanel: the intent section as prose, then an open-questions panel headed with its count; each question shows its text, blocking flag, key and address when keyed, its decider, links, provisional reading and trigger when present, and its carrier line when recorded, with an action that composes intent scoped to it. -- behaviorPanel: rules and flows in authored order, a keyed entry with its rule address and the examples that exercise it as recipe 34 reports, followed by example cards laid out as `spec:consumers.spec-studio.verification-panels` states. +The panels the core does not hold, in page order. +- intentPanel: beside each open question, an action that composes intent scoped to it. +- behaviorPanel: beside each keyed rule or flow, the examples that exercise it as recipe 34 reports, then example cards laid out as `spec:consumers.spec-studio.verification-panels` states. - constraintsPanel: each constraint with its target, laid out as `spec:consumers.spec-studio.verification-panels` states. -- designPanel: a diagram of the components that realize the Spec, from its implementation anchors' `component` and `uses` edges, drawn as SVG whose nodes show their id, source file and line, with their destination awaiting `spec:consumers.spec-studio.spec-page#question.sourceLinks`; below it, the `design` entries, then the decision records the Spec names by `decidedBy` with each decision's text inline. +- designPanel: a diagram of the components that realize the Spec, from its implementation anchors' `component` and `uses` edges, drawn as SVG whose nodes show their id, source file and line as the source-text rule states; below the design entries, the decision records the Spec names by `decidedBy`, each with its text inline. - runtimePanel: the structural neighborhood of the Spec's implementation anchors: each anchor's component and the code units it uses, with file and line. -- bindingsPanel: implementation bindings, design references, verifier bindings and the expected-outcome oracle, each with its id and recorded repository-relative file and line, shown as text with an action that copies file:line, plus runtime observation reading not tracked; source navigation awaits `spec:consumers.spec-studio.spec-page#question.sourceLinks`. - verificationPanel: the verifiers and the coverage of the example space, laid out as `spec:consumers.spec-studio.verification-panels` states. - evidencePanel: the evidence fields and absence labels `spec:consumers.spec-studio.verification-panels` states, with no invented build, deployment, measurement or SBOM data. -- relationsPanel: relations both ways, each with its type, claim, endpoint, resolution, reason and exercises when present; a resolving Spec endpoint links to its page. - impactPanel: the upstream parents and downstream children, the Specs that depend on this one, the decision records it names, the tests that verify it, and the code its bindings reach. -- uiPanel: the `ui` entries. -- entryIds: every addressable keyed Design or UI entry, every keyed entry of the primary behavior owner and every keyed open question carries an HTML element id equal to its entry address suffix, the section and lower-camel key without the Spec id or `#`, so a link opens the entry on its page. diff --git a/specs/consumers/spec-studio.verification-panels.sdp.md b/specs/consumers/spec-studio.verification-panels.sdp.md index 34ffd86a..ff6172a0 100644 --- a/specs/consumers/spec-studio.verification-panels.sdp.md +++ b/specs/consumers/spec-studio.verification-panels.sdp.md @@ -18,23 +18,14 @@ relations: --- # The panels for examples, targets, verifiers and evidence +The graph records that a verifier binding exists and never a run, and a static page runs nothing, so the panels show bindings and no run result, and offer no run of an example. No Spec designs a source for build attestations, deployments or SBOMs, so each reads not tracked. + ## Intent - outcome: Show on a Spec page how far each example, constraint target and verifier goes, and what runtime evidence exists, without stating more than the graph records. -### Open questions -- [non-blocking #runResults] The original shows each test's runner, whether it passed, its duration and its last run, and offers to run an example again; the graph records that a verifier binding exists and never a run, and a static page runs nothing. Does a panel show run results read from CI output when the Studio is built, or bindings only? - - decider: owner - - links: spec:observation.run-evidence - - provisional: The first Studio build shows bindings only; admitting run records in a later phase awaits an explicit input and reproducibility ruling. -- [non-blocking #coverageSource] Is per-slot coverage sufficient for the Studio grid, or does it need the combinations still open in spec:consumers.agent-surface.example-space-coverage#question.combinationCoverage? - - decider: owner - - links: spec:consumers.agent-surface.example-space-coverage#question.combinationCoverage - - provisional: per-slot coverage for the grid; combinations wait for an adopter's design pass -- [non-blocking #deliveryArtifacts] The original's evidence panel shows the last build with its SLSA attestation, the last deployment and a CycloneDX SBOM; no Spec designs a source for any of them. Does the Studio show them, and from which input? - - decider: owner - - provisional: each reads not tracked until a Spec designs its source ## Behavior - rule: An example card shows the example's verifier as a binding that exists, and the example as an Executable Spec when a test anchor binds it, never as a test that passed. +- rule: [#bindingsOnly] A panel shows verifier bindings and no run result; run records join a panel only through `spec:observation.run-evidence`, under an input and reproducibility ruling of their own. - rule: [#evidenceNotTracked] The evidence panel shows only runtime evidence the graph records, and reads not tracked while no delivery fact records an observation. - rule: Each panel action gathers intent through the composing panel's contract. @@ -45,6 +36,6 @@ The panels' parts in page order. - unwrittenExampleCard: an example with no given, when and then steps yet says so, and offers to compose intent proposing them. - constraintTarget: each constraint with its flavor and statement, its target drawn as a bar beside the number, and a measured value only when the graph records one, otherwise not tracked; moving the bar composes intent to change the target, and the new value shows on the page only while the intent waits in the composing panel. - unsetTarget: a constraint with no quantitative target says so, and offers to compose intent proposing one. -- verifierList: each verifier with its id, its binding, and its recorded repository-relative file and line; no line is invented for a verifier the graph does not locate, and source navigation awaits `spec:consumers.spec-studio.spec-page#question.sourceLinks`. -- coverageGrid: when the Spec owns an example space, the coverage `spec:consumers.agent-surface.example-space-coverage` reports, listing each literal value of each slot with the examples that bind it and listing a non-literal-union slot as not enumerable; an action composes intent proposing examples for missing values. -- evidencePanel: runtime observation reads not tracked under the binding-language rule; build attestations, deployments and SBOMs show no payload while the graph has no designed record for them, and each reads not tracked instead. +- verifierList: each verifier with its id, its binding, and its recorded repository-relative file and line as text, as `spec:consumers.spec-studio.spec-page#rule.sourceText` states; no line is invented for a verifier the graph does not locate. +- coverageGrid: when the Spec owns an example space, the per-slot coverage `spec:consumers.agent-surface.example-space-coverage` reports, listing each literal value of each slot with the examples that bind it and listing a non-literal-union slot as not enumerable; combinations of slots join the grid only when a design pass asks for them; an action composes intent proposing examples for missing values. +- evidencePanel: runtime observation reads not tracked under the binding-language rule; build attestations, deployments and SBOMs show no payload and each reads not tracked until a Spec designs its source. diff --git a/specs/consumers/validate-watch.sdp.md b/specs/consumers/validate-watch.sdp.md index 143ab4ce..d211ebc2 100644 --- a/specs/consumers/validate-watch.sdp.md +++ b/specs/consumers/validate-watch.sdp.md @@ -19,5 +19,5 @@ relations: - rule: `sdp validate --watch [root]` installs its watcher first, then runs the same validate path `sdp validate` runs, from scratch, once at start and again after every carrier create, change, delete or rename under the root. - rule: A carrier ends in `.sdp.md`, `.sdp.gherkin` or `.sdp.ts`. The watcher ignores the directories discovery skips, the configured exclusion prefixes and every non-carrier path; a removed watched directory and an event with no filename each schedule a run because their affected carriers cannot be named. - rule: Events that arrive while a run is in progress coalesce into one pending run. -- rule: Findings print as `sdp validate` prints them and the process stays alive; an operator stop exits 0. +- rule: [#operatorStop] Findings print as `sdp validate` prints them and the process stays alive; operator SIGINT terminates the process with exit 0 even while a rules body is active, without waiting for it. No further rules file runs or output is written after the stop. - rule: `--watch` is a validate option only and cannot combine with `--check-clean`. It re-runs on carrier edits and never on source edits, so an author re-runs validation after editing an anchor. diff --git a/specs/corpus-checks.pack.sdp.md b/specs/corpus-checks.pack.sdp.md new file mode 100644 index 00000000..a19cd2b3 --- /dev/null +++ b/specs/corpus-checks.pack.sdp.md @@ -0,0 +1,20 @@ +--- +id: pack:corpus-checks-v1 +specs: + - spec:validation.corpus-rules + - spec:validation.corpus-rules.error-finding-fails + - spec:validation.corpus-rules.warning-finding-informs + - spec:validation.corpus-rules.malformed-finding-fails + - spec:validation.corpus-rules.copies-contain-changes + - spec:validation.architecture-constraints + - spec:validation.architecture-constraints.forbidden-layer-pair + - spec:validation.architecture-constraints.isolated-context-as-rule + - spec:validation.architecture-constraints.unstated-layer-unclassified + - spec:model.planned-architecture + - spec:model.planned-architecture.unrealized-design + - spec:model.planned-architecture.differing-layer-and-uses + - spec:model.planned-architecture.matching-design +--- +# Corpus checks + +This Pack groups what a corpus needs to check its own policy on the one validation path, in reading order. Corpus rules are the corpus's own graph bodies that `sdp validate --rules` runs and reports apart from the Protocol's findings. The architecture walk is a catalog recipe a corpus runs as one of those rules, over the layers, contexts and uses its anchors state. Component designs state the components a design adds before their code exists, and a catalog recipe compares them with the components the anchors later realize. Each Spec is followed by the examples that will verify it. diff --git a/specs/extraction/example-runner.sdp.md b/specs/extraction/example-runner.sdp.md index ca03dc7c..4a59b79c 100644 --- a/specs/extraction/example-runner.sdp.md +++ b/specs/extraction/example-runner.sdp.md @@ -17,6 +17,7 @@ relations: ## Behavior - rule: [#contractOrder] The core plans every contract step in authored order and runs it against the world the caller hands in; creating a fresh world per example is the adapter's lifecycle, never the core's. +- rule: [#worldCleanup] Once the Vitest adapter creates a world, it awaits the optional `after` hook whether the steps pass or fail. A step failure stays the test's failure and an error from `after` is then discarded; when the steps pass, an error from `after` fails the test. A world factory that throws creates no world, so `after` does not run. - rule: [#oneHandlerPerStep] Duplicate step text within one example binds one handler, and every occurrence runs it; an occurrence that binds a slot to a different value is a conflicting re-binding, named at generation, and every occurrence runs with the first binding, because an example is one point. - rule: [#redStepNamesItself] A red step names itself before the assertion detail: the failure message leads with the step's natural reading — the Spec's own words with bound values inlined — and the original error is preserved, carried as `cause` when it cannot be re-messaged, and wrapped when the thrown value is not an error. - rule: A missing or stale step handler is a compile-time refusal rather than a silent skip: the bindings type covers every step and only the steps, so spec-side drift fails the typecheck instead of the run. diff --git a/specs/model/planned-architecture.differing-layer-and-uses.sdp.md b/specs/model/planned-architecture.differing-layer-and-uses.sdp.md new file mode 100644 index 00000000..5fee2626 --- /dev/null +++ b/specs/model/planned-architecture.differing-layer-and-uses.sdp.md @@ -0,0 +1,23 @@ +--- +id: spec:model.planned-architecture.differing-layer-and-uses +kind: example +altitude: story +readiness: ready +relations: + refines: spec:model.planned-architecture + verifies: spec:model.planned-architecture +--- +# A realized component that differs from its design gives one row per field + +## Intent +- outcome: Execute the realization comparison on one realized component whose layer and uses differ from its design. +- assumption: The world writes the probe root of the unrealized-design example and adds a comment-form anchor for the planned component that satisfies the design Spec and states the realized layer, with one `impl:` member whose anchor `uses` the member of the component the realized-uses slot names. + +```gwt +Given a component design for {componentId: "component:probe.history"} that plans the layer {plannedLayer: "domain"} and uses {plannedUses: "component:probe.kernel"} +Given the component anchor for that design {anchor: "satisfies the design"} +Given that anchor states the layer {realizedLayer: "application"} and its member uses {realizedUses: "component:probe.audit"} +When the realization comparison runs +Then the comparison lists {unrealized: 0} unrealized designs, {matching: 0} matching designs and {differing: 2} differing rows +Then the differing fields are {fields: "layer, uses"} +``` diff --git a/specs/model/planned-architecture.matching-design.sdp.md b/specs/model/planned-architecture.matching-design.sdp.md new file mode 100644 index 00000000..a5018e9a --- /dev/null +++ b/specs/model/planned-architecture.matching-design.sdp.md @@ -0,0 +1,23 @@ +--- +id: spec:model.planned-architecture.matching-design +kind: example +altitude: story +readiness: ready +relations: + refines: spec:model.planned-architecture + verifies: spec:model.planned-architecture +--- +# A realized component that matches its design gives no row + +## Intent +- outcome: Execute the realization comparison on one realized component whose layer and uses match its design. +- assumption: The world writes the probe root of the differing example, with the realized layer and uses the slots give. + +```gwt +Given a component design for {componentId: "component:probe.history"} that plans the layer {plannedLayer: "domain"} and uses {plannedUses: "component:probe.kernel"} +Given the component anchor for that design {anchor: "satisfies the design"} +Given that anchor states the layer {realizedLayer: "domain"} and its member uses {realizedUses: "component:probe.kernel"} +When the realization comparison runs +Then the comparison lists {unrealized: 0} unrealized designs, {matching: 1} matching designs and {differing: 0} differing rows +Then the differing fields are {fields: ""} +``` diff --git a/specs/model/planned-architecture.sdp.md b/specs/model/planned-architecture.sdp.md index f0982d5e..5c70adfb 100644 --- a/specs/model/planned-architecture.sdp.md +++ b/specs/model/planned-architecture.sdp.md @@ -2,7 +2,7 @@ id: spec:model.planned-architecture kind: model altitude: feature -readiness: defined +readiness: ready relations: dependsOn: target: spec:model.anchors @@ -15,22 +15,33 @@ The original design declared a component before its code, as a Component node wi The Protocol records the architecture the code realizes. A `component:` anchor states its layer and context, and anchored `memberOf` and `uses` edges join the code units. Under the architectural annotation decision, a component no code realizes is a Spec, and the graph holds no node for it until an anchor exists. The first adopter states its component list and its context rule in prose (`libar-platform/design/PLAN.md` §2.1) and checks context isolation over anchored `uses` in its own script (`libar-platform/design/tools/check.py`). The owner ruled that the architectural annotation decision stands: a component designed before its code is a component design Spec, and the `component:` anchor that later realizes it satisfies that Spec, which is the join a comparison reads. +Choosing a provider stays prose. The original's distinctions between the interface a unit requires, the providers that supply it, the one a composition root selects and the one a test substitutes help a reader, and none of them needs graph structure beyond `uses` to be compared. Keeping one composition mechanism per application is a corpus's own architecture rule when the corpus wants it, stated in a `constraint` Spec and checked by its own corpus rule. A component design names its component with the keys a component anchor uses, so the comparison reads the same words on both sides, and a key the design leaves out is not compared, so a design states only what it has decided; a design that has decided its component uses nothing says `none`, which a left-out key cannot say. + ## Intent - problem: A design ahead of its code cannot state the components it adds, their layer and context and what they use, in a form the graph compares with the components the code later anchors. - outcome: Let a design name the components it will add, with their layer, context and uses, and compare that design with the anchored components once code exists. - risk: A planned component kept apart from its realization is a second place for the component to drift, which is why the architectural annotation decision refused a separate architecture file and why the design lives in a Spec the realizing anchor satisfies. -### Open questions -- [non-blocking #providerSelection] The original kept apart the interface a unit requires, the providers that supply it, the provider a composition root selects and the one a test substitutes, for Fastify, Effect, Awilix and plain factories alike. Does a component design state these without naming a framework, and does any of them earn graph structure beyond `uses`? - - decider: owner -- [non-blocking #oneRuntimeOwner] Is the original's one-runtime-truth rule, one composition mechanism per application, a Protocol rule or a corpus's own architecture rule? - - decider: owner -- [non-blocking #comparisonShape] How is a component design selected, what value form does its `uses` entry take, and what rows does the realization comparison return for an unrealized design and for a layer, context or `uses` that differs? - - decider: owner - - provisional: a Spec is a component design when its Design `id` value, bare or in the code span that opens it, is a `component:` id, as the curation report reads it - ## Model -- **component design** — A Spec that states a component the code does not yet realize and what it is for. Its Design entries are keyed as the anchor's attributes are named: `id` for the planned `component:` anchor's own id, `layer`, `context`, `uses` for the components it will use, and optionally `role`. The keys are a convention a recipe reads; the Protocol closes no Design shape, and the Spec confers nothing and mints no code unit. +- **component design** — A Spec that states a component the code does not yet realize and what it is for. Its Design entries are keyed as the anchor's attributes are named: `id` for the planned `component:` anchor's own id, `layer`, `context`, `uses` for the components it will use, and optionally `role`. The keys are a convention recipes read; the Protocol closes no Design shape, and the Spec confers nothing and mints no code unit. - **planned component** — The component a component design names. It stays intent until a `component:` anchor realizes it, and that anchor satisfies the component design; the graph holds no component node before the anchor exists. -- **realization comparison** — A catalog recipe that reads each component design beside the `component:` anchor that satisfies it, through the component data recipe 32 joins, and lists a design no anchor satisfies yet, a layer or context that differs, and a `uses` named on one side only. It reports and refuses nothing. -- **provider** — A code unit that supplies an interface another unit requires; a composition selects one provider for each requirement, and a test may select another. +- **realization comparison** — Recipe 43, planned against realized components, which reads each component design beside the `component:` anchor that satisfies it, resolving members through `memberOf` as recipe 32 does, and lists a design no anchor satisfies yet, a layer, context or role that differs, and a `uses` named on one side only. It reports and refuses nothing. +- **provider** — A code unit that supplies an interface another unit requires; a composition selects one provider for each requirement, and a test may select another. A component design names its providers and that selection in prose, and none of them earns graph structure beyond `uses`. + +## Design +- componentDesign: a Spec is a component design when its Design `id` value, bare or in the code span that opens it, is a `component:` id; the curation report and the realization comparison select designs this way, and no validator reads the convention. +- designKeys: the comparison reads five Design keys, `id`, `layer`, `context`, `role` and `uses`, and no other; `layer` takes one of the five layers, `context` and `role` one token on the role grammar, and `uses` either `none` for the empty list or `component:` ids separated by commas, each trimmed, read as the set of distinct ids; each value is read bare when it is the whole value, or from the code span that opens it, with prose after the span free to say why. +- comparisonRecipe: recipe 43, planned against realized components, takes no parameter, so any parameter name returns `{ unknownParams, accepts: [] }` alone, as every catalog recipe answers a name it does not take; otherwise it returns `{ totals, unrealized, differing, unreadable }`, where `totals` is `{ designs, realized, matching, unrealized, differing, unreadable }`, `matching` counts the realized designs with neither a differing row nor an unreadable row, and `differing` counts rows. +- realizedWhen: a design is realized when its `component:` CodeNode is anchored and has an anchored `satisfies` edge to the design, as the curation report reads it; a design whose component exists without that edge stays unrealized, with `anchorExists: true`. +- comparedFields: for a realized design, each of `layer`, `context`, `role` and `uses` the design states is compared with the anchored component, and a key the design leaves out is not compared; the realized `uses` are the components other than its own that an anchored `uses` edge from the component or one of its members reaches, each end resolved to its component as recipe 32 resolves it. +- comparisonRows: `unrealized` rows are `{ spec, componentId, anchorExists, layer, context, role, uses }` with the planned values, null when unstated, and `uses` an empty list when the design states `none`; `differing` rows are `{ spec, componentId, field, planned, realized }`, one for each differing field, where `layer`, `context` and `role` carry the two values or null and `uses` carries the two sorted lists of components named on that side only; `unreadable` rows are `{ spec, key, value }` for a value outside its key's form, and that key is not compared; rows sort by `spec`, then by `field` or `key`. + +## Example space +```gwt-vocabulary +Given a component design for {componentId:string} that plans the layer {plannedLayer:"domain"|"application"} and uses {plannedUses:string} +Given the component anchor for that design {anchor:"is absent"|"satisfies the design"} +Given that anchor states the layer {realizedLayer:"domain"|"application"|"absent"} and its member uses {realizedUses:string} +When the realization comparison runs +Then the comparison lists {unrealized:number} unrealized designs, {matching:number} matching designs and {differing:number} differing rows +Then the differing fields are {fields:string} +``` diff --git a/specs/model/planned-architecture.unrealized-design.sdp.md b/specs/model/planned-architecture.unrealized-design.sdp.md new file mode 100644 index 00000000..8f2e0017 --- /dev/null +++ b/specs/model/planned-architecture.unrealized-design.sdp.md @@ -0,0 +1,23 @@ +--- +id: spec:model.planned-architecture.unrealized-design +kind: example +altitude: story +readiness: ready +relations: + refines: spec:model.planned-architecture + verifies: spec:model.planned-architecture +--- +# A component design no anchor satisfies is listed as unrealized + +## Intent +- outcome: Execute the realization comparison on one component design whose component no anchor realizes yet. +- assumption: The world writes a probe root holding one story-altitude model Spec stating `idea` with an Intent outcome, one Model term and a Design section whose `id`, `layer` and `uses` entries carry the slots' planned values, bare. Beside it the root holds comment-form anchors for `component:probe.kernel` and `component:probe.audit`, each with one `impl:` member. The world runs the comparison's shipped recipe file through `sdp q --json`; the differing fields are the `field` values of the `differing` rows joined by a comma and a space, in answer order, and an empty string names none. + +```gwt +Given a component design for {componentId: "component:probe.history"} that plans the layer {plannedLayer: "domain"} and uses {plannedUses: "component:probe.kernel"} +Given the component anchor for that design {anchor: "is absent"} +Given that anchor states the layer {realizedLayer: "absent"} and its member uses {realizedUses: ""} +When the realization comparison runs +Then the comparison lists {unrealized: 1} unrealized designs, {matching: 0} matching designs and {differing: 0} differing rows +Then the differing fields are {fields: ""} +``` diff --git a/specs/self-hosting.pack.sdp.md b/specs/self-hosting.pack.sdp.md index 70717b0b..b36868cd 100644 --- a/specs/self-hosting.pack.sdp.md +++ b/specs/self-hosting.pack.sdp.md @@ -68,6 +68,9 @@ specs: - spec:model.open-question-keys.repeated-key-refused - spec:model.design-maturity - spec:model.planned-architecture + - spec:model.planned-architecture.unrealized-design + - spec:model.planned-architecture.differing-layer-and-uses + - spec:model.planned-architecture.matching-design - spec:model.open-question-fields - spec:model.rule-keys - spec:model.relation-reasons @@ -147,7 +150,14 @@ specs: - spec:validation.next-rung-floor.blocking-question - spec:validation.superseded-decision-signal - spec:validation.architecture-constraints + - spec:validation.architecture-constraints.forbidden-layer-pair + - spec:validation.architecture-constraints.isolated-context-as-rule + - spec:validation.architecture-constraints.unstated-layer-unclassified - spec:validation.corpus-rules + - spec:validation.corpus-rules.copies-contain-changes + - spec:validation.corpus-rules.error-finding-fails + - spec:validation.corpus-rules.malformed-finding-fails + - spec:validation.corpus-rules.warning-finding-informs - spec:consumers.projections-model - spec:consumers.projections-model.roadmap-views - spec:consumers.impact-graph @@ -190,10 +200,16 @@ specs: - spec:consumers.wholesale-view-rewrite.build-invalidates-view - spec:consumers.agent-surface.address-and-cycle-recipes - spec:consumers.spec-studio + - spec:consumers.spec-studio.index-page + - spec:consumers.spec-studio.pack-page + - spec:consumers.spec-studio.publisher + - spec:consumers.spec-studio.search + - spec:consumers.spec-studio.page-elements - spec:consumers.spec-studio.data - spec:consumers.spec-studio.shell - spec:consumers.spec-studio.lenses - spec:consumers.spec-studio.spec-page + - spec:consumers.spec-studio.spec-page.core - spec:consumers.spec-studio.verification-panels - spec:consumers.spec-studio.intent-panel - spec:consumers.spec-studio.components diff --git a/specs/validation/architecture-constraints.forbidden-layer-pair.sdp.md b/specs/validation/architecture-constraints.forbidden-layer-pair.sdp.md new file mode 100644 index 00000000..803af519 --- /dev/null +++ b/specs/validation/architecture-constraints.forbidden-layer-pair.sdp.md @@ -0,0 +1,24 @@ +--- +id: spec:validation.architecture-constraints.forbidden-layer-pair +kind: example +altitude: story +readiness: ready +relations: + refines: spec:validation.architecture-constraints + verifies: + target: spec:validation.architecture-constraints + exercises: [walk] +--- +# A domain member that uses an infrastructure member breaks a forbidden pair + +## Intent +- outcome: Execute the walk through `sdp q` on one `uses` edge between members of two components whose layers form a forbidden pair. +- assumption: The world writes a probe root with no Spec and two comment-form `component:` anchors, each with one `impl:` member; the using member's anchor `uses` the used member. Each component states the layer and context its slots give, and the Protocol's validators find the root clean. The world runs the walk's shipped recipe file through `sdp q --json` with `params` `{ "forbidden": [{ "from": "domain", "to": "infrastructure" }], "isolated": ["platform"] }`; the rule ids are the findings' `validatorId` values joined by a comma and a space, in answer order. + +```gwt +Given a member of a component at the layer {fromLayer: "domain"} in the context {fromContext: "orders"} that uses a member of a component at the layer {toLayer: "infrastructure"} in the context {toContext: "orders"} +When the architecture walk runs {invocation: "through sdp q"} with the forbidden pair domain to infrastructure and the isolated context platform +Then the walk reports {findings: 1} findings with the rule ids {ruleIds: "forbidden-layer"} +Then the walk lists {unclassified: 0} unclassified edges +Then the command exits {exitCode: 0} +``` diff --git a/specs/validation/architecture-constraints.isolated-context-as-rule.sdp.md b/specs/validation/architecture-constraints.isolated-context-as-rule.sdp.md new file mode 100644 index 00000000..80c024ab --- /dev/null +++ b/specs/validation/architecture-constraints.isolated-context-as-rule.sdp.md @@ -0,0 +1,23 @@ +--- +id: spec:validation.architecture-constraints.isolated-context-as-rule +kind: example +altitude: story +readiness: ready +relations: + refines: spec:validation.architecture-constraints + verifies: + target: spec:validation.architecture-constraints + exercises: [walk, walkAsRule] +--- +# The walk named as a rules file fails validation on an isolation breach + +## Intent +- outcome: Execute the walk's shipped recipe file, unchanged, as a rules file under `sdp validate --rules` on one `uses` edge that leaves an isolated context. +- assumption: The world writes the probe root of the forbidden-pair example, with the layers and contexts the slots give. It runs `sdp validate` on that root with the walk's shipped recipe file named by `--rules` and followed by `--params` with the parameters of that example. The findings are the lines in that file's corpus section, and the rule ids are their rule ids joined by a comma and a space, in output order. + +```gwt +Given a member of a component at the layer {fromLayer: "application"} in the context {fromContext: "platform"} that uses a member of a component at the layer {toLayer: "adapter"} in the context {toContext: "orders"} +When the architecture walk runs {invocation: "as a rules file named by sdp validate --rules"} with the forbidden pair domain to infrastructure and the isolated context platform +Then the walk reports {findings: 1} findings with the rule ids {ruleIds: "isolated-context"} +Then the command exits {exitCode: 1} +``` diff --git a/specs/validation/architecture-constraints.sdp.md b/specs/validation/architecture-constraints.sdp.md index 336c6dbd..b8166a3a 100644 --- a/specs/validation/architecture-constraints.sdp.md +++ b/specs/validation/architecture-constraints.sdp.md @@ -2,7 +2,7 @@ id: spec:validation.architecture-constraints kind: rule altitude: feature -readiness: defined +readiness: ready relations: dependsOn: - spec:model.anchors @@ -15,22 +15,35 @@ The original design gave architecture a validation tier of its own. Its rules sa The first adopter checks that a component in its `platform` context has no `uses` edge to a component in another context, resolving each end through `memberOf`. Architecture crossings, recipe 32, reports differences in context or layer and refuses nothing. The architectural annotation decision keeps architecture checks in the corpus's own gate, and the Protocol supplies the reusable graph walk. +The walk returns the edges a rule forbids as corpus findings, so one body answers a reader through `sdp q` and fails a gate when the corpus names it as a rules file; the first adopter's isolation check becomes the walk named by `sdp validate --rules` with the isolated context `platform`, and none of its own code. An edge the walk cannot decide, because a component states no layer or context or an end belongs to no component, is listed apart: a breach the anchors cannot show is a curation gap, not a finding. + ## Intent - problem: A corpus that states an architecture rule in a Spec cannot have it checked by the Protocol, so each adopter writes the same walk over `uses`, `memberOf`, `layer` and `context` in its own gate. - outcome: Check a corpus's declared architecture rules, such as a forbidden uses direction or context isolation, against the layers, contexts and uses its anchors state. -### Open questions -- [non-blocking #ruleForms] Do corpus rules need to require a component to state a layer or context, or check ports and routes that anchors do not carry, beyond the selected forbidden layer pairs and isolated contexts? - - decider: owner - - provisional: the supplied walk checks forbidden layer pairs and isolated contexts; absent attributes are unclassified rather than breaches -- [non-blocking #walkInvocation] Which pure callable walk serves both the catalog recipe and a loaded corpus rule, and what rule-data and result shapes do they share? - - decider: owner - - provisional: one pure walk takes `params.forbidden`, a list of `{ from, to }` layer pairs, and `params.isolated`, a list of context tokens whose components may have no `uses` edge into another context, either list possibly empty; it returns each forbidden edge as `{ from, to, fromComponent, toComponent, rule }` and lists unclassified edges apart; recipe 32's result stays as it is - ## Rule - The Protocol's validators never judge a corpus's architecture; an architecture rule runs as one of the corpus's own rules, the corpus's gate decides what fails, and that gate may be the corpus's own invocation of `sdp validate --rules` with the severity the rule states. - A corpus states each architecture rule as intent in a `constraint` Spec that says what is forbidden and why, and keeps the rule's machine form, its forbidden layer pairs and isolated contexts, as data its corpus rule passes to the walk. -- [#walk] The Protocol supplies the walk as a catalog recipe that takes forbidden layer pairs and isolated contexts as recipe parameters, resolves each end of every anchored `uses` edge to its component, directly or through `memberOf`, and returns each edge a rule forbids with both units, both components and the rule. +- [#walk] The Protocol supplies the walk as a catalog recipe, recipe 42, that takes forbidden layer pairs and isolated contexts as recipe parameters, resolves each end of every anchored `uses` edge to its component, directly or through `memberOf`, and returns each edge a rule forbids with both units, both components and the rule. +- [#walkAsRule] The walk returns the edges it forbids as corpus findings in its answer's `findings`, so the shipped recipe file runs unchanged as a rules file under `sdp validate --rules`, at the severity the corpus passes it. +- [#forms] The walk checks two forms, forbidden layer pairs and isolated contexts. It requires no component to state a layer or a context and checks no port, route or other fact the anchors do not carry; a corpus that wants such a check writes a rule of its own. - The walk reads the given rules and anchored structure only; it infers no edge from imports, reads no file path, judges no role and refuses nothing. -- A component that states no layer or context falls outside a rule that names one, and the walk reports it as unclassified rather than as a breach. +- [#unclassified] A component that states no layer or context falls outside a rule that names one, and the walk lists the edge as unclassified rather than as a breach, as it lists an edge with an end that belongs to no component. - The corpus binds a discovered TypeScript check site to its constraint Spec through a test anchor; that check invokes the corpus rule, and `has-verifier` states that the check exists while its result stays in the corpus's run. + +## Design +- walkParams: recipe 42, the architecture walk, takes `params.forbidden`, a list of `{ from, to }` pairs of layers from the closed set; `params.isolated`, a list of context tokens; and `params.severity`, `"error"` by default or `"warning"`; either list may be empty, and a pair or context given twice counts once; a parameter name outside these three returns `{ unknownParams, accepts }` alone, before any other check, as every catalog recipe answers a name it does not take; neither list given, a layer outside the set, a malformed token or another severity returns `{ diagnostics }` with no `findings`; and either answer fails the file when the walk runs as a rules file. +- walkAnswer: `{ input: { forbidden, isolated, severity }, totals: { usesEdges, resolved, findings, unclassified }, findings, unclassified }` is the answer when every parameter name is known and every value lawful, where `resolved` counts the `uses` edges whose two ends both resolve to a component; findings sort by `from`, then `to`, then rule id, and unclassified rows by `from`, then `to`; recipe 32's answer stays as it is. +- walkBreach: an anchored `uses` edge whose ends resolve to two distinct components breaks the pair `{ from, to }` when the using component states layer `from` and the used one states layer `to`, and breaks the isolation of a context when the using component states that context and the used one states another; two units of one component never break a rule, as recipe 32 never counts them as crossing. +- walkFinding: `{ validatorId, subjectId, relatedId, severity, message, from, to, fromComponent, toComponent, rule }`, a corpus finding in the shape `spec:validation.corpus-rules#design.ruleFinding` pins, where `subjectId` is the using unit `from`, `relatedId` the used unit `to`, both components are `{ id, layer, context }` with null for an unstated attribute, and `validatorId` is `forbidden-layer` with `rule: { from, to }` the pair it breaks, or `isolated-context` with `rule: { context }` the isolated context it leaves. +- walkMessages: a `forbidden-layer` message reads `<from> uses <to>: layer <from layer> uses layer <to layer> (<fromComponent id> to <toComponent id>), a forbidden pair`, and an `isolated-context` message reads `<from> uses <to>: <fromComponent id> in the isolated context <context> uses <toComponent id> in the context <other context>`. +- walkUnclassified: `unclassified` rows `{ from, to, fromComponent, toComponent, missing }` list each anchored `uses` edge the walk cannot decide while a list is non-empty: an edge with an end that belongs to no component, that end's component null and `missing: ["component"]`, and an edge between two distinct components where either leaves unstated an attribute a non-empty list reads, `layer` for `forbidden` and `context` for `isolated`, named in `missing`. + +## Example space +```gwt-vocabulary +Given a member of a component at the layer {fromLayer:"domain"|"application"} in the context {fromContext:"platform"|"orders"} that uses a member of a component at the layer {toLayer:"infrastructure"|"adapter"|"unstated"} in the context {toContext:"platform"|"orders"} +When the architecture walk runs {invocation:"through sdp q"|"as a rules file named by sdp validate --rules"} with the forbidden pair domain to infrastructure and the isolated context platform +Then the walk reports {findings:number} findings with the rule ids {ruleIds:string} +Then the walk lists {unclassified:number} unclassified edges +Then the command exits {exitCode:number} +``` diff --git a/specs/validation/architecture-constraints.unstated-layer-unclassified.sdp.md b/specs/validation/architecture-constraints.unstated-layer-unclassified.sdp.md new file mode 100644 index 00000000..ee0aa766 --- /dev/null +++ b/specs/validation/architecture-constraints.unstated-layer-unclassified.sdp.md @@ -0,0 +1,24 @@ +--- +id: spec:validation.architecture-constraints.unstated-layer-unclassified +kind: example +altitude: story +readiness: ready +relations: + refines: spec:validation.architecture-constraints + verifies: + target: spec:validation.architecture-constraints + exercises: [unclassified] +--- +# A component with no layer leaves its edge unclassified + +## Intent +- outcome: Execute the walk through `sdp q` on one `uses` edge whose used component states no layer. +- assumption: The world writes the probe root of the forbidden-pair example, except that the used component's anchor states no layer, which the `unstated` value names. It runs the walk as that example does; an empty rule-id string names no finding. + +```gwt +Given a member of a component at the layer {fromLayer: "domain"} in the context {fromContext: "platform"} that uses a member of a component at the layer {toLayer: "unstated"} in the context {toContext: "platform"} +When the architecture walk runs {invocation: "through sdp q"} with the forbidden pair domain to infrastructure and the isolated context platform +Then the walk reports {findings: 0} findings with the rule ids {ruleIds: ""} +Then the walk lists {unclassified: 1} unclassified edges +Then the command exits {exitCode: 0} +``` diff --git a/specs/validation/corpus-rules.copies-contain-changes.sdp.md b/specs/validation/corpus-rules.copies-contain-changes.sdp.md new file mode 100644 index 00000000..472da440 --- /dev/null +++ b/specs/validation/corpus-rules.copies-contain-changes.sdp.md @@ -0,0 +1,26 @@ +--- +id: spec:validation.corpus-rules.copies-contain-changes +kind: example +altitude: story +readiness: ready +relations: + refines: spec:validation.corpus-rules + verifies: + target: spec:validation.corpus-rules + exercises: [copiesPerFile, namedOrder] +--- +# A rules file that empties its graph leaves the next file's graph whole + +## Intent +- outcome: Execute the copy rule on two rules files, the first changing the graph it receives and the second reading its own. +- assumption: The world writes the probe corpus of the error example. The first rules file's body sets the length of `graph.nodes` and of `graph.edges` to zero and returns `{ findings: [] }`. The second rules file's body returns one warning finding when `graph.nodes` is empty and `{ findings: [] }` otherwise. The world runs `sdp validate` on the probe root with the first file, then the second, named by `--rules`, and reads the exit code and standard output. + +```gwt +Given a probe corpus the Protocol's validators find clean +Given a first rules file that {first: "empties the graph it receives"} +Given a second rules file that returns one warning when the graph it receives holds no node +When the probe corpus is validated with the rules files named by --rules in that order +Then the command exits {exitCode: 0} +Then the Protocol summary line reads {protocolSummary: "validate: 0 errors · 0 warnings (conformance + honesty over the one graph)"} +Then the corpus summary line reads {corpusSummary: "corpus rules: 0 errors · 0 warnings · 0 of 2 files failed (the corpus's own rules)"} +``` diff --git a/specs/validation/corpus-rules.error-finding-fails.sdp.md b/specs/validation/corpus-rules.error-finding-fails.sdp.md new file mode 100644 index 00000000..aa3db87a --- /dev/null +++ b/specs/validation/corpus-rules.error-finding-fails.sdp.md @@ -0,0 +1,25 @@ +--- +id: spec:validation.corpus-rules.error-finding-fails +kind: example +altitude: story +readiness: ready +relations: + refines: spec:validation.corpus-rules + verifies: + target: spec:validation.corpus-rules + exercises: [exitCode] +--- +# An error from a corpus rule fails validation and leaves the Protocol's line alone + +## Intent +- outcome: Execute the exit rule on one rules file that returns one error finding over a corpus the Protocol finds clean. +- assumption: The world writes a probe corpus of two story-altitude behavior Specs stating `idea`, each with an Intent outcome, the second declaring `refines` on the first, so the Protocol's validators report no finding. The rules file's body returns `{ findings: [{ validatorId: "probe-rule", subjectId: "probe-subject", severity: "error", message: "the probe rule reports its subject" }] }`. The world runs `sdp validate` on the probe root with that file named by `--rules` and reads the exit code and standard output. + +```gwt +Given a probe corpus the Protocol's validators find clean +Given a first rules file that {first: "returns one error finding"} +When the probe corpus is validated with the rules files named by --rules in that order +Then the command exits {exitCode: 1} +Then the Protocol summary line reads {protocolSummary: "validate: 0 errors · 0 warnings (conformance + honesty over the one graph)"} +Then the corpus summary line reads {corpusSummary: "corpus rules: 1 errors · 0 warnings · 0 of 1 files failed (the corpus's own rules)"} +``` diff --git a/specs/validation/corpus-rules.malformed-finding-fails.sdp.md b/specs/validation/corpus-rules.malformed-finding-fails.sdp.md new file mode 100644 index 00000000..81e566da --- /dev/null +++ b/specs/validation/corpus-rules.malformed-finding-fails.sdp.md @@ -0,0 +1,25 @@ +--- +id: spec:validation.corpus-rules.malformed-finding-fails +kind: example +altitude: story +readiness: ready +relations: + refines: spec:validation.corpus-rules + verifies: + target: spec:validation.corpus-rules + exercises: [brokenRuleFails] +--- +# A finding with no subject fails its rules file + +## Intent +- outcome: Execute the broken-rule rule on one rules file whose only finding carries no subject. +- assumption: The world writes the probe corpus of the error example. The rules file's body returns `{ findings: [{ validatorId: "probe-rule", severity: "warning", message: "the probe rule forgets its subject" }] }`, a warning that would pass if it were well formed. The world runs `sdp validate` on the probe root with that file named by `--rules`, reads the exit code and standard output, and reads on standard error the failure line naming the first finding's `subjectId`. + +```gwt +Given a probe corpus the Protocol's validators find clean +Given a first rules file that {first: "returns a finding with no subject"} +When the probe corpus is validated with the rules files named by --rules in that order +Then the command exits {exitCode: 1} +Then the Protocol summary line reads {protocolSummary: "validate: 0 errors · 0 warnings (conformance + honesty over the one graph)"} +Then the corpus summary line reads {corpusSummary: "corpus rules: 0 errors · 0 warnings · 1 of 1 files failed (the corpus's own rules)"} +``` diff --git a/specs/validation/corpus-rules.sdp.md b/specs/validation/corpus-rules.sdp.md index c13cf13e..02bcf8cf 100644 --- a/specs/validation/corpus-rules.sdp.md +++ b/specs/validation/corpus-rules.sdp.md @@ -2,12 +2,18 @@ id: spec:validation.corpus-rules kind: behavior altitude: feature -readiness: defined +readiness: ready relations: dependsOn: - spec:consumers.agent-surface - target: spec:validation.diagnostic-rendering reason: corpus findings use the shared diagnostic currency + - target: spec:consumers.agent-surface.evaluation-sink + reason: a rules file holds the body the sink evaluates, with the sink's bindings and trust + - target: spec:consumers.agent-surface.recipe-parameters + reason: validate takes `--params` in the sink's grammar + - target: spec:consumers.validate-watch + reason: the watch loop reruns the named rules in each cycle constrainedBy: spec:validation.two-check-families decidedBy: target: spec:decisions.architectural-annotation @@ -19,28 +25,48 @@ The original design let a team add its own rules beside the built-in validators, The first adopter's gate runs `sdp validate` and parses its summary line, then checks its own context isolation, entry addresses, dependency cycles, question keys, prose mentions, cited laws and Pack pages. It also parses carrier frontmatter to read kind, readiness and relations, a second read model that a graph-based corpus-rule runner would remove. +A rules file holds the body `sdp q` evaluates, so a rule tried through `sdp q` runs unchanged under `sdp validate`, and a catalog recipe whose answer carries findings, such as the architecture walk, is a rules file as shipped. The runner reads only the answer's findings, so the recipe keeps the rest of its answer for a reader. Corpus findings print after the Protocol's and are tallied on a line of their own, so a gate that reads the Protocol's summary line reads it unchanged; any error, the Protocol's or the corpus's, exits 1, and the two lines say which. Each rules file receives its own copy of what it reads, so a careless rule cannot change another rule's input and the order of the files changes only the order of the output. Its parameters are its own too: a catalog recipe answers a parameter name it does not take with that name and no findings, so parameters shared by every file would fail the walk on the names another file reads. + ## Intent - problem: A corpus with rules of its own checks them off the one validation path, reading the carriers a second way and reporting in a form no Protocol surface reads. - outcome: Let a corpus author its own graph rules and have their findings join the one validation report, so no corpus re-parses its carriers to check itself. -### Open questions -- [non-blocking #rulesRepresentation] Does an operator-named rules file hold one async body returning findings or a module exporting named rule functions, how are parameters supplied, in what order do several files run, and does `sdp validate --watch` rerun them? - - decider: owner - - provisional: one async body per file, given `g`, `graph`, `report` and `params`, with `params` from `sdp validate --params` and `{}` without it; files run in the order named, each to completion; a rule id is unique within its file; `sdp validate --watch` runs the same named rules each cycle -- [non-blocking #findingCurrency] What shape does a corpus finding take beside the one diagnostic currency, where does the report carry it, and does it reach `report`, the reader's `findings()` or the Design Review? - - decider: owner - - provisional: the shared finding contract has a Protocol branch carrying `validatorId`, `family`, severity and message, and a corpus branch carrying the corpus, the rule id as `validatorId`, `subjectId`, severity and message and no family; `relatedId`, `path`, `file` and `line` stay optional on both branches; corpus findings are collected as `corpusFindings` in the same report and rendered under a corpus-labelled section with the existing location, severity, identifier and message composition; Protocol findings, the summary line and the Design Review stay unchanged, as the frozen-projections decision requires -- [non-blocking #ruleIsolation] Does the runner enforce that a rule reads only the graph and changes nothing, or are both obligations on the rule's author? - - decider: owner - - provisional: changing nothing is a guarantee, with the Protocol's validators run first and each rule given a frozen or cloned graph; reading only the graph is the author's obligation, run with the trust `sdp q` asks and no sandbox - ## Behavior - rule: A corpus rule is a graph body the corpus authors, given `g`, `graph`, `report` and `params` as a recipe body is, and returning findings that each carry a rule id, a subject, a message and a severity. -- rule: A corpus rule reads the one derived graph and nothing else, so no rule reads a carrier, a source file or the network. -- rule: Each corpus finding names its rule and its corpus as its source and stands apart from the Protocol's own findings; the Protocol states none of these rules and ships none. -- rule: Corpus findings stand outside the conformance and honesty families, in a section of the report labelled as the corpus's own, so the two families keep their meaning and the guardrail that checks never judge content quality or workflow keeps binding the Protocol's own validators. +- rule: [#readsGraphOnly] A corpus rule reads the one derived graph, the Protocol's report and the `params` the operator supplies for its file, and no carrier, source file or network; that is the rule author's obligation, because the runner evaluates the body with the trust `sdp q` asks and claims no sandbox. - rule: [#operatorNamed] Validation runs corpus rules only from a file the operator names with `sdp validate --rules PATH`, which may repeat, and never from a file it finds in the corpus or in a configuration; it asks the trust `sdp q` already asks, to run code the operator chose. +- rule: [#namedOrder] Rules files run one at a time in the order named, each to completion, after the Protocol's validators; every file receives the same Protocol report and none sees another file's findings, so the order changes only the order of the output. +- rule: [#copiesPerFile] Each rules file receives its own copy of the graph and the report, made after the graph is written, and its own `params`, so a body that changes what it receives changes no node, edge, delivery fact or Protocol finding, and nothing another rules file receives. +- rule: Each rules file receives as `params` only the parameters the operator names after its `--rules` and before the next `--rules`, or `{}` when none follow it, so no file receives a parameter meant for another. +- rule: A rule id names one check within its rules file; two files may use the same id, and their findings stay apart by the file that reported them. +- rule: Each corpus finding names its rule and its rules file as its source. The Protocol states none of these rules: a shipped recipe that a corpus names as a rules file checks the rule data the corpus passes it. +- rule: Corpus findings stand outside the conformance and honesty families, in a section of the output labelled as the corpus's own and tallied on a summary line of their own, so the two families keep their meaning, the guardrail that checks never judge content quality or workflow keeps binding the Protocol's own validators, and no corpus finding adds to, suppresses or changes a Protocol finding or the exit reason it sets. - rule: [#exitCode] A corpus finding carries severity `error` or `warning`. When the operator names `--rules`, an `error` corpus finding makes `sdp validate` exit nonzero and a `warning` stays informative; without `--rules` no corpus rule runs and only the Protocol's validators set the exit code. -- rule: Corpus findings keep their own source and report section, are tallied apart from the Protocol's summary line, and neither add to, suppress nor change a Protocol finding or the exit reason it sets. -- rule: [#brokenRuleFails] A rules file that cannot load, a rule that throws, or a rule that returns a finding without a rule id, subject, message and admitted severity is a rule execution failure; `sdp validate` reports it in the corpus section and exits nonzero, so a broken rule never passes as a clean run. -- rule: A corpus rule changes no node, edge or delivery fact; it reports on the graph it is given. +- rule: [#neverMovesFloor] A corpus finding moves no readiness floor and reaches no other surface: not the report `sdp q` hands a body, not the reader, not the written graph, and not the Design Review or another projection. +- rule: [#brokenRuleFails] A rules file that cannot be read or does not parse, a body that throws, or an answer that is not an object with a `findings` array of well-formed findings is a rule execution failure; `sdp validate` reports it in the corpus section and exits nonzero, so a broken rule never passes as a clean run. A failed file reports none of its findings, and the other files still run. +- rule: [#watchReruns] `sdp validate --watch` with `--rules` reads and runs every named rules file afresh in each cycle, after the Protocol's validators; an edit to a rules file alone starts no cycle, because the watcher watches carriers. +- rule: When the graph does not derive, no rules file runs, and the corpus summary line says so. + +## Design +- rulesFlag: `sdp validate [root] [--exclude PATH]... [--check-clean | --watch] [--rules PATH [--params JSON | --params @PATH | --params KEY=@PATH]...]...`; `--rules` and `--params` are options of `validate` alone, a `--params` before the first `--rules` is refused, one file named twice is refused, including through a symlink, and each PATH resolves from the working directory, as a `--params @PATH` file does. Diagnostics retain the path the operator named. +- paramsFlag: `--params` on `validate` takes the three forms of `sdp q --params` with the same refusals, before the graph derives; each `--params` belongs to the nearest `--rules` before it in the argument list, whatever other options stand between them; one file's parameters merge as `sdp q` merges them, a key given twice for one file is refused, two files may each take the same key, and a file with no `--params` after it receives `{}`. +- rulesFile: a rules file is UTF-8 text holding one plain JavaScript async function body, the form `sdp q` evaluates, with the bindings `g`, `graph`, `report` and `params`; the runner reads the text and never imports it as a module, so no file extension is required and a catalog recipe file under `dist/recipes/` is a lawful rules file. +- ruleAnswer: `{ findings: [] }`: the body returns an object whose `findings` is an array, possibly empty; the runner reads `findings` and nothing else, so the answer's other keys stay the body's own data and are never printed. +- ruleFinding: `{ validatorId, subjectId, severity, message, relatedId?, path?, file?, line? }`, where `validatorId` is the rule id, one token matching `^[a-z][a-z0-9-]*$`, so it never reads as a Protocol validator id; `subjectId` and `message` are non-empty strings; `severity` is `"error"` or `"warning"`; `relatedId` and `path` are strings; `file` is a non-empty root-relative POSIX path, with absolute paths, backslashes and traversal above the root refused, and `line` a positive integer that requires `file`; the runner ignores a finding's other keys. +- corpusFinding: `{ rulesFile, validatorId, subjectId, severity, message, relatedId?, path?, file?, line? }` is the corpus branch of the finding currency, where `rulesFile` is the path as the operator named it; it carries no `family`, and the Protocol's findings keep their shape. +- corpusReport: the report a run with `--rules` builds keeps the Protocol's findings unchanged in `findings` and adds `corpusFindings`, the corpus findings of each file that did not fail, in the order the files are named and then in the order returned, and `corpusFailures`, one `{ rulesFile, reason }` for each failed file in the order named, never a finding; the report a rules file receives, like the one `sdp q` hands a body, carries neither. +- corpusSection: on standard error, after every Protocol finding, each rules file in the order named prints the line `corpus rules <path>` and then its findings in the order returned, each in the one composition of location, severity in brackets, rule id and message; a failed file prints `corpus rules <path> failed: <reason>` in place of its findings. +- corpusSummary: `corpus rules: <errors> errors · <warnings> warnings · <failed> of <files> files failed (the corpus's own rules)`, written to standard output directly after the Protocol's summary line, which stays as `spec:validation.diagnostic-rendering#design.summaryLine` pins it; without `--rules` no such line prints; when the graph does not derive, no rules file runs and no Protocol summary line prints, and the corpus line, the last line on standard output, reads `corpus rules: not run, the graph did not derive`. +- exitCodes: with `--rules`, `sdp validate` exits 1 when a Protocol finding is an error, a corpus finding is an error, a rules file fails or the graph does not derive, and 0 otherwise; the two summary lines say which. +- failureReasons: `<reason>` is `cannot be read (<cause>)`, `does not parse at line <L>, column <C>: <cause>`, `threw: <message on one line>`, `returned no findings array`, followed by `; diagnostics: <the answer's diagnostics as JSON>` when the answer carries `diagnostics` or by `; unknownParams: <as JSON>; accepts: <as JSON>` when it carries `unknownParams`, or `finding <N> has no lawful <field>`, with N counted from 1. + +## Example space +```gwt-vocabulary +Given a probe corpus the Protocol's validators find clean +Given a first rules file that {first:"returns one error finding"|"returns one warning finding"|"returns a finding with no subject"|"empties the graph it receives"} +Given a second rules file that returns one warning when the graph it receives holds no node +When the probe corpus is validated with the rules files named by --rules in that order +Then the command exits {exitCode:number} +Then the Protocol summary line reads {protocolSummary:string} +Then the corpus summary line reads {corpusSummary:string} +``` diff --git a/specs/validation/corpus-rules.warning-finding-informs.sdp.md b/specs/validation/corpus-rules.warning-finding-informs.sdp.md new file mode 100644 index 00000000..d059a96e --- /dev/null +++ b/specs/validation/corpus-rules.warning-finding-informs.sdp.md @@ -0,0 +1,25 @@ +--- +id: spec:validation.corpus-rules.warning-finding-informs +kind: example +altitude: story +readiness: ready +relations: + refines: spec:validation.corpus-rules + verifies: + target: spec:validation.corpus-rules + exercises: [exitCode] +--- +# A warning from a corpus rule informs and passes + +## Intent +- outcome: Execute the exit rule on one rules file that returns one warning finding over a corpus the Protocol finds clean. +- assumption: The world writes the probe corpus of the error example. The rules file's body returns `{ findings: [{ validatorId: "probe-rule", subjectId: "probe-subject", severity: "warning", message: "the probe rule reports its subject" }] }`. The world runs `sdp validate` on the probe root with that file named by `--rules` and reads the exit code and standard output. + +```gwt +Given a probe corpus the Protocol's validators find clean +Given a first rules file that {first: "returns one warning finding"} +When the probe corpus is validated with the rules files named by --rules in that order +Then the command exits {exitCode: 0} +Then the Protocol summary line reads {protocolSummary: "validate: 0 errors · 0 warnings (conformance + honesty over the one graph)"} +Then the corpus summary line reads {corpusSummary: "corpus rules: 0 errors · 1 warnings · 0 of 1 files failed (the corpus's own rules)"} +``` diff --git a/specs/validation/prose-mentions.sdp.md b/specs/validation/prose-mentions.sdp.md index e19b45b6..b3577752 100644 --- a/specs/validation/prose-mentions.sdp.md +++ b/specs/validation/prose-mentions.sdp.md @@ -25,5 +25,5 @@ relations: - A token whose Spec exists and whose entry address names no key of that `design` or `ui` section, names `description` there, names a question key no open question of that Spec carries, or names a rule key that Spec's `behavior.ruleKeys` does not carry, is an error with the message `Mention in "<spec>" at <path> points to missing entry "<section>.<key>" of "<target>".` - Errors are reported once per scanning Spec, text path, and distinct token; `subjectId` is the scanning Spec, `relatedId` the token as written, `path` the text path, and `file` the Spec's carrier. The path is `narrative` or the section path in the mention audit's form, such as `intent.openQuestions[0].question`, `behavior.rules[2]`, `design.step2`, or `model.terms.<term>`. - A target Spec that exists and shares no declared `refines`, `dependsOn`, `constrainedBy`, `decidedBy`, `verifies`, or `supersedes` edge with the scanning Spec, in either direction, is one warning per scanning Spec and target Spec pair, counting every location where any token names that Spec or one of its entries. The message is `Mention of "<target>" in "<spec>" at <n> locations, first at <path>, with no declared relation between them; informative only. Declare the relation that fits, or leave the mention as prose and let the warning stand when none does.` with `<n>` the count of distinct paths, written `1 location` when it is one, and `<path>` the first in scan order; `subjectId` is the scanning Spec, `relatedId` the target Spec id, `path` that first path, and `file` the Spec's carrier. An address on the scanning Spec's own id is checked for its entry and never warned. -- A mention error or pair warning carries `line` when the location table records the corresponding text path; a question link uses the `question[<i>].links` row, and an unrecorded line is omitted. +- A mention error or pair warning carries `line` when the location table records the corresponding text path; `behavior.rules[<i>]` uses `rule[<i>]`, `behavior.flows[<i>]` uses `flow[<i>]`, an open question's text uses `question[<i>]`, and a question field uses `question[<i>].<field>`, with each question link using the `links` row. Entries of Rule, Workflow and Contract use the same behavior paths as entries of Behavior. An unrecorded line is omitted. - The warning is honest when it stands: the six relations are typed, a mention that none of them describes is lawful prose, and no author declares a relation to silence the report. Warnings never fail `sdp validate`; the mention audit recipe reports every location of every pair as data and stays the way to list them at once. diff --git a/src/adapters/vitest.ts b/src/adapters/vitest.ts index 545615b2..03acf36e 100644 --- a/src/adapters/vitest.ts +++ b/src/adapters/vitest.ts @@ -46,8 +46,18 @@ export function bindExample<W, S extends string, PM extends Record<S, ParamShape it("executes the bound example against a fresh world", async () => { // The adapter's lifecycle law: the fresh world is created here, per example. const freshWorld = world(); - await runExamplePlan(plan, freshWorld); - await after?.(freshWorld); + let stepsPassed = false; + try { + await runExamplePlan(plan, freshWorld); + stepsPassed = true; + } finally { + try { + await after?.(freshWorld); + } catch (error) { + // eslint-disable-next-line no-unsafe-finally -- Only a passing step run can be replaced by a cleanup error. + if (stepsPassed) throw error; + } + } }); }); } diff --git a/src/cli/build-args.ts b/src/cli/build-args.ts index 0ddb2ad5..49aff275 100644 --- a/src/cli/build-args.ts +++ b/src/cli/build-args.ts @@ -1,4 +1,5 @@ -import { statSync } from "node:fs"; +import { mergeParams } from "./params.js"; +import { realpathSync, statSync } from "node:fs"; import { resolve } from "node:path"; import { InvalidExcludePathError, normalizeExcludes } from "../extract/discover.js"; @@ -11,6 +12,12 @@ export interface BuildArgs { readonly checkClean: boolean; readonly watch?: boolean; readonly imports?: string; + readonly rules?: readonly RulesInput[]; +} + +export interface RulesInput { + readonly rulesFile: string; + readonly params: Readonly<Record<string, unknown>>; } export function parseBuildArgs( @@ -23,6 +30,8 @@ export function parseBuildArgs( let watchMode = false; let imports: string | undefined; const rawExcludes: string[] = []; + const rawRules: { rulesFile: string; forms: string[] }[] = []; + const rulePaths = new Set<string>(); for (let index = 0; index < args.length; index += 1) { const argument = args[index]; @@ -31,6 +40,47 @@ export function parseBuildArgs( continue; } + if (argument === "--rules" || argument === "--params") { + if (command !== "validate") { + writeStderr(output, `sdp ${command}: ${argument} is only valid on validate.\n`); + return undefined; + } + const value = args[index + 1]; + if (value === undefined || value.trim() === "" || value.startsWith("--")) { + writeStderr( + output, + `sdp validate: ${argument} requires ${argument === "--rules" ? "a path" : "a JSON object, @PATH or KEY=@PATH"}.\n`, + ); + return undefined; + } + if (argument === "--rules") { + let path = resolve(value); + try { + path = realpathSync(path); + } catch { + // An unreadable file remains the runner's failure, with the operator's path. + } + if (rulePaths.has(path)) { + writeStderr( + output, + `sdp validate: --rules file ${JSON.stringify(value)} was given twice.\n`, + ); + return undefined; + } + rulePaths.add(path); + rawRules.push({ rulesFile: value, forms: [] }); + } else { + const last = rawRules.at(-1); + if (last === undefined) { + writeStderr(output, "sdp validate: --params must follow its --rules file.\n"); + return undefined; + } + last.forms.push(value); + } + index += 1; + continue; + } + if (argument === "--check-clean") { checkClean = true; continue; @@ -114,6 +164,13 @@ export function parseBuildArgs( return undefined; } + const rules: RulesInput[] = []; + for (const item of rawRules) { + const params = mergeParams(item.forms, output, "validate"); + if (params === undefined) return undefined; + rules.push({ rulesFile: item.rulesFile, params }); + } + const resolvedRoot = resolveExtractionRoot(root, output, command); return resolvedRoot === undefined @@ -122,6 +179,7 @@ export function parseBuildArgs( root: resolvedRoot, exclude, checkClean, + ...(rules.length === 0 ? {} : { rules }), watch: watchMode, ...(imports === undefined ? {} : { imports }), }; diff --git a/src/cli/build-artifacts.ts b/src/cli/build-artifacts.ts index 1e275c37..cff572bd 100644 --- a/src/cli/build-artifacts.ts +++ b/src/cli/build-artifacts.ts @@ -156,11 +156,16 @@ export type GitRunner = (cwd: string, args: readonly string[]) => string; const runGit: GitRunner = (cwd, args) => execFileSync("git", [...args], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); -/** The full hash of the commit checked out at `root`, or `unknown` when git cannot name one. */ +/** Git names a checkout first; a substituted archive file can name a build without git. */ export function readBuildCommit(root: string, git: GitRunner = runGit): string { try { const commit = git(root, ["rev-parse", "HEAD"]).trim(); - + if (isFullCommit(commit)) return commit; + } catch { + // Hosted git dependencies may build from an archive with no .git directory. + } + try { + const commit = readFileSync(join(root, ".git-archive-commit"), "utf8").trim(); return isFullCommit(commit) ? commit : UNKNOWN_COMMIT; } catch { return UNKNOWN_COMMIT; diff --git a/src/cli/build-command.ts b/src/cli/build-command.ts index 909605a1..d1390083 100644 --- a/src/cli/build-command.ts +++ b/src/cli/build-command.ts @@ -213,6 +213,7 @@ function readRegistrarManifest(path: string, root: string, rootReal: string): re * @sdpComponent component:protocol.cli * @sdpRole publisher */ + const wholesaleViewBuildInvalidationAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.wholesale-view-build-invalidation"), label: "invalidates the prior Design Review before every build attempt", @@ -242,6 +243,13 @@ const regenerabilityAnchor = codeAnchor({ component: componentAnchorId("component:protocol.cli"), role: "service", }); +/** + * @sdpAnchor impl:protocol.studio-build-invalidation + * @sdpLabel removes the Studio root and temporary sibling before shared extraction + * @sdpSatisfies spec:consumers.spec-studio.publisher + * @sdpComponent component:protocol.cli + * @sdpRole service + */ export function runBuild( parsed: BuildArgs, output: CliOutput, @@ -273,6 +281,7 @@ export function runBuild( join(resolvedRoot, "generated", "census"), join(resolvedRoot, "generated", "mermaid"), join(resolvedRoot, "generated", "gherkin"), + join(resolvedRoot, "generated", "spec-studio"), ]; const failBuild = (message: string): BuildOutcome => { diff --git a/src/cli/corpus-rules.ts b/src/cli/corpus-rules.ts new file mode 100644 index 00000000..b60076ab --- /dev/null +++ b/src/cli/corpus-rules.ts @@ -0,0 +1,128 @@ +import { readFileSync } from "node:fs"; +import { posix, win32 } from "node:path"; +import type { RulesInput } from "./build-args.js"; +import { compileBody } from "./q-command.js"; +import { errorMessage, oneLine } from "./output.js"; +import type { GraphSchema } from "../graph/schema.js"; +import { createReader } from "../reader/reader.js"; +import type { + CorpusFinding, + CorpusValidationReport, + ValidationReport, +} from "../validate/contracts.js"; +function object(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function nonempty(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} +function rootRelativeFile(value: unknown): value is string { + if ( + !nonempty(value) || + value.includes("\\") || + posix.isAbsolute(value) || + win32.isAbsolute(value) + ) + return false; + const normalized = posix.normalize(value); + return normalized !== ".." && !normalized.startsWith("../"); +} +function checkFinding(value: unknown): string | undefined { + const row = object(value) ? value : {}; + if (typeof row.validatorId !== "string" || !/^[a-z][a-z0-9-]*$/u.test(row.validatorId)) + return "validatorId"; + if (!nonempty(row.subjectId)) return "subjectId"; + if (row.severity !== "error" && row.severity !== "warning") return "severity"; + if (!nonempty(row.message)) return "message"; + for (const field of ["relatedId", "path"]) + if (row[field] !== undefined && typeof row[field] !== "string") return field; + if (row.file !== undefined && !rootRelativeFile(row.file)) return "file"; + if ( + row.line !== undefined && + (typeof row.line !== "number" || + !Number.isInteger(row.line) || + row.line < 1 || + row.file === undefined) + ) + return "line"; + return undefined; +} +function copyFinding(value: Record<string, unknown>, rulesFile: string): CorpusFinding { + return { + rulesFile, + validatorId: value.validatorId as string, + subjectId: value.subjectId as string, + severity: value.severity as CorpusFinding["severity"], + message: value.message as string, + ...Object.fromEntries( + ["relatedId", "path", "file", "line"] + .filter((key) => value[key] !== undefined) + .map((key) => [key, value[key]]), + ), + }; +} + +/** Runs each operator-selected body against independent copies and commits only a lawful answer. */ +export async function runCorpusRules( + graph: GraphSchema, + report: ValidationReport, + files: readonly RulesInput[], +): Promise<CorpusValidationReport> { + const corpusFindings: CorpusFinding[] = []; + const corpusFailures: { rulesFile: string; reason: string }[] = []; + for (const { rulesFile, params } of files) { + let source: string; + try { + source = readFileSync(rulesFile, "utf8"); + } catch (error) { + corpusFailures.push({ + rulesFile, + reason: `cannot be read (${oneLine(errorMessage(error))})`, + }); + continue; + } + let body: ReturnType<typeof compileBody>; + try { + body = compileBody(source); + } catch (error) { + corpusFailures.push({ + rulesFile, + reason: oneLine(errorMessage(error)).replace(/^the body /u, ""), + }); + continue; + } + try { + const cloned = structuredClone(graph); + const answer: unknown = await body( + createReader(cloned), + cloned, + structuredClone(report), + structuredClone(params), + ); + if (!object(answer) || !Array.isArray(answer.findings)) { + let reason = "returned no findings array"; + if (object(answer) && Object.hasOwn(answer, "diagnostics")) + reason += `; diagnostics: ${JSON.stringify(answer.diagnostics)}`; + else if (object(answer) && Object.hasOwn(answer, "unknownParams")) + reason += `; unknownParams: ${JSON.stringify(answer.unknownParams)}; accepts: ${JSON.stringify(answer.accepts)}`; + corpusFailures.push({ rulesFile, reason }); + continue; + } + const findings: CorpusFinding[] = []; + let failure: string | undefined; + for (const [index, value] of (answer.findings as unknown[]).entries()) { + const field = checkFinding(value); + if (field !== undefined) { + failure = `finding ${String(index + 1)} has no lawful ${field}`; + break; + } + findings.push(copyFinding(value as Record<string, unknown>, rulesFile)); + } + if (failure === undefined) corpusFindings.push(...findings); + else corpusFailures.push({ rulesFile, reason: failure }); + } catch (error) { + corpusFailures.push({ rulesFile, reason: `threw: ${oneLine(errorMessage(error))}` }); + } + } + return { ...report, corpusFindings, corpusFailures }; +} diff --git a/src/cli/output.ts b/src/cli/output.ts index 0d7139ca..30cc1ddf 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -1,4 +1,4 @@ -import type { Finding } from "../validate/contracts.js"; +import type { CorpusFinding, Finding } from "../validate/contracts.js"; import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; import { codeAnchor } from "../model/code-anchor.js"; @@ -29,7 +29,7 @@ const diagnosticCliAnchor = codeAnchor({ }); void diagnosticCliAnchor; -export function formatFinding(finding: Finding): string { +export function formatFinding(finding: Finding | CorpusFinding): string { const location = finding.file === undefined ? "" @@ -41,3 +41,8 @@ export function formatFinding(finding: Finding): string { export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } + +/** Collapses an error message onto one line, so a refusal stays the one-line diagnostic. */ +export function oneLine(message: string): string { + return message.replace(/\s+/gu, " ").trim(); +} diff --git a/src/cli/params.ts b/src/cli/params.ts new file mode 100644 index 00000000..1d808dfe --- /dev/null +++ b/src/cli/params.ts @@ -0,0 +1,147 @@ +import { readFileSync } from "node:fs"; +import type { CliOutput } from "./output.js"; +import { errorMessage, oneLine, writeStderr } from "./output.js"; + +/** How a refusal names a parsed JSON value that is not an object. */ +function jsonKind(value: unknown): string { + return value === null ? "null" : Array.isArray(value) ? "an array" : `a ${typeof value}`; +} + +/** Inspect object-key tokens before JSON.parse discards repeated keys. Nested values stay opaque. */ +function repeatedTopLevelKey(source: string): string | undefined { + if (!source.trimStart().startsWith("{")) return undefined; + const keys = new Set<string>(); + let depth = 0; + for (let index = 0; index < source.length; index++) { + const character = source[index]; + if (character === '"') { + const start = index; + for (index++; index < source.length; index++) { + if (source[index] === "\\") index++; + else if (source[index] === '"') break; + } + if (depth !== 1) continue; + let next = index + 1; + while (/[ \t\r\n]/u.test(source[next] ?? "")) next++; + if (source[next] !== ":") continue; + const key: unknown = JSON.parse(source.slice(start, index + 1)); + if (typeof key !== "string") continue; + if (keys.has(key)) return key; + keys.add(key); + } else if (character === "{" || character === "[") depth++; + else if (character === "}" || character === "]") depth--; + } + return undefined; +} + +/** + * `--params` carries a recipe's parameter as data, never as body source: a JSON object inline, or + * `@PATH` naming a file that holds one, or `KEY=@PATH` binding any JSON value to one key. + * Paths resolve from the working directory. Each input is read and parsed + * here, before the graph derives, so a value that is not JSON, JSON that is not an object, or a + * file that cannot be read refuses the invocation before the body could run. + */ +function readParams( + value: string, + output: CliOutput, + command: string, +): { readonly params: Readonly<Record<string, unknown>> } | undefined { + let text = value; + let source = "--params"; + let key: string | undefined; + // Inline JSON and @PATH are recognized first, so `=@` inside either is never read as a key. + const keyed = /^[ \t\r\n]*[{["]|^@/u.test(value) ? null : /^([^=]+)=@(.*)$/su.exec(value); + if (keyed !== null) { + key = keyed[1]; + if (key === undefined || !/^[a-z][A-Za-z0-9]*$/u.test(key)) { + writeStderr(output, `sdp ${command}: --params KEY=@PATH requires a lower-camel key.\n`); + return undefined; + } + } + + if (value.startsWith("@") || keyed !== null) { + const path = keyed === null ? value.slice(1) : (keyed[2] ?? ""); + + if (path === "") { + writeStderr(output, `sdp ${command}: --params @PATH requires a path after @.\n`); + return undefined; + } + + source = `--params file ${JSON.stringify(path)}`; + + try { + // A byte-order mark is not JSON text, though an editor may save one; it is dropped. + text = readFileSync(path, "utf8").replace(/^\uFEFF/u, ""); + } catch (error) { + writeStderr( + output, + `sdp ${command}: ${source} could not be read (${oneLine(errorMessage(error))}).\n`, + ); + return undefined; + } + } + + let parsed: unknown; + + try { + if (key === undefined) { + const repeated = repeatedTopLevelKey(text); + if (repeated !== undefined) { + writeStderr( + output, + `sdp ${command}: --params key ${JSON.stringify(repeated)} was given twice.\n`, + ); + return undefined; + } + } + parsed = JSON.parse(text) as unknown; + } catch (error) { + writeStderr( + output, + `sdp ${command}: ${source} is not valid JSON (${oneLine(errorMessage(error))}).\n`, + ); + return undefined; + } + + if (key !== undefined) { + return { params: { [key]: parsed } }; + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + writeStderr( + output, + `sdp ${command}: ${source} must be a JSON object, got ${jsonKind(parsed)}.\n`, + ); + return undefined; + } + + return { params: parsed as Readonly<Record<string, unknown>> }; +} + +export function mergeParams( + forms: readonly string[], + output: CliOutput, + command: string, +): Readonly<Record<string, unknown>> | undefined { + const params: Record<string, unknown> = {}; + for (const value of forms) { + const supplied = readParams(value, output, command); + if (supplied === undefined) return undefined; + for (const [key, item] of Object.entries(supplied.params)) { + if (Object.hasOwn(params, key)) { + writeStderr( + output, + `sdp ${command}: --params key ${JSON.stringify(key)} was given twice.\n`, + ); + return undefined; + } + Object.defineProperty(params, key, { + value: item, + enumerable: true, + writable: true, + configurable: true, + }); + } + } + return params; +} diff --git a/src/cli/q-command.ts b/src/cli/q-command.ts index e19c928a..ada03650 100644 --- a/src/cli/q-command.ts +++ b/src/cli/q-command.ts @@ -1,3 +1,4 @@ +import { mergeParams } from "./params.js"; import { readFileSync } from "node:fs"; import { isatty } from "node:tty"; import { inspect } from "node:util"; @@ -14,7 +15,7 @@ import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; import { codeAnchor } from "../model/code-anchor.js"; import { resolveExtractionRoot } from "./build-args.js"; import type { CliOutput } from "./output.js"; -import { errorMessage, formatFinding, writeStderr, writeStdout } from "./output.js"; +import { errorMessage, formatFinding, oneLine, writeStderr, writeStdout } from "./output.js"; /** * The agent front door (`spec:decisions.agent-front-door`): one evaluation sink over the same @@ -139,43 +140,6 @@ function defaultReadStdin(): string { return readFileSync(0, "utf8"); } -/** Collapses an error message onto one line, so a refusal stays the one-line diagnostic. */ -function oneLine(message: string): string { - return message.replace(/\s+/gu, " ").trim(); -} - -/** How a refusal names a parsed JSON value that is not an object. */ -function jsonKind(value: unknown): string { - return value === null ? "null" : Array.isArray(value) ? "an array" : `a ${typeof value}`; -} - -/** Inspect object-key tokens before JSON.parse discards repeated keys. Nested values stay opaque. */ -function repeatedTopLevelKey(source: string): string | undefined { - if (!source.trimStart().startsWith("{")) return undefined; - const keys = new Set<string>(); - let depth = 0; - for (let index = 0; index < source.length; index++) { - const character = source[index]; - if (character === '"') { - const start = index; - for (index++; index < source.length; index++) { - if (source[index] === "\\") index++; - else if (source[index] === '"') break; - } - if (depth !== 1) continue; - let next = index + 1; - while (/[ \t\r\n]/u.test(source[next] ?? "")) next++; - if (source[next] !== ":") continue; - const key: unknown = JSON.parse(source.slice(start, index + 1)); - if (typeof key !== "string") continue; - if (keys.has(key)) return key; - keys.add(key); - } else if (character === "{" || character === "[") depth++; - else if (character === "}" || character === "]") depth--; - } - return undefined; -} - const queryParamsAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.query-params"), label: "reads --params as JSON data and injects it as the sink's fourth binding, params", @@ -185,80 +149,6 @@ const queryParamsAnchor = codeAnchor({ }); void queryParamsAnchor; -/** - * `--params` carries a recipe's parameter as data, never as body source: a JSON object inline, or - * `@PATH` naming a file that holds one, or `KEY=@PATH` binding any JSON value to one key. - * Paths resolve from the working directory. Each input is read and parsed - * here, before the graph derives, so a value that is not JSON, JSON that is not an object, or a - * file that cannot be read refuses the invocation before the body could run. - */ -function readParams( - value: string, - output: CliOutput, -): { readonly params: Readonly<Record<string, unknown>> } | undefined { - let text = value; - let source = "--params"; - let key: string | undefined; - // Inline JSON and @PATH are recognized first, so `=@` inside either is never read as a key. - const keyed = /^[ \t\r\n]*[{["]|^@/u.test(value) ? null : /^([^=]+)=@(.*)$/su.exec(value); - if (keyed !== null) { - key = keyed[1]; - if (key === undefined || !/^[a-z][A-Za-z0-9]*$/u.test(key)) { - writeStderr(output, "sdp q: --params KEY=@PATH requires a lower-camel key.\n"); - return undefined; - } - } - - if (value.startsWith("@") || keyed !== null) { - const path = keyed === null ? value.slice(1) : (keyed[2] ?? ""); - - if (path === "") { - writeStderr(output, "sdp q: --params @PATH requires a path after @.\n"); - return undefined; - } - - source = `--params file ${JSON.stringify(path)}`; - - try { - // A byte-order mark is not JSON text, though an editor may save one; it is dropped. - text = readFileSync(path, "utf8").replace(/^\uFEFF/u, ""); - } catch (error) { - writeStderr( - output, - `sdp q: ${source} could not be read (${oneLine(errorMessage(error))}).\n`, - ); - return undefined; - } - } - - let parsed: unknown; - - try { - if (key === undefined) { - const repeated = repeatedTopLevelKey(text); - if (repeated !== undefined) { - writeStderr(output, `sdp q: --params key ${JSON.stringify(repeated)} was given twice.\n`); - return undefined; - } - } - parsed = JSON.parse(text) as unknown; - } catch (error) { - writeStderr(output, `sdp q: ${source} is not valid JSON (${oneLine(errorMessage(error))}).\n`); - return undefined; - } - - if (key !== undefined) { - return { params: { [key]: parsed } }; - } - - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - writeStderr(output, `sdp q: ${source} must be a JSON object, got ${jsonKind(parsed)}.\n`); - return undefined; - } - - return { params: parsed as Readonly<Record<string, unknown>> }; -} - export function parseQueryArgs(args: readonly string[], output: CliOutput): QueryArgs | undefined { let root: string | undefined; let body: string | undefined; @@ -359,23 +249,8 @@ export function parseQueryArgs(args: readonly string[], output: CliOutput): Quer return undefined; } - const params: Record<string, unknown> = {}; - for (const value of rawParams) { - const supplied = readParams(value, output); - if (supplied === undefined) return undefined; - for (const [key, item] of Object.entries(supplied.params)) { - if (Object.hasOwn(params, key)) { - writeStderr(output, `sdp q: --params key ${JSON.stringify(key)} was given twice.\n`); - return undefined; - } - Object.defineProperty(params, key, { - value: item, - enumerable: true, - writable: true, - configurable: true, - }); - } - } + const params = mergeParams(rawParams, output, "q"); + if (params === undefined) return undefined; return { root: resolvedRoot, exclude, body, json, params }; } diff --git a/src/cli/sdp.ts b/src/cli/sdp.ts index 86c6d387..6ba1da03 100644 --- a/src/cli/sdp.ts +++ b/src/cli/sdp.ts @@ -19,9 +19,11 @@ import { defaultCliOutput, errorMessage, writeStderr, writeStdout } from "./outp import type { CliOutput } from "./output.js"; import { parseQueryArgs, runQuery } from "./q-command.js"; import type { QueryHooks } from "./q-command.js"; +import { runStudio } from "./studio-command.js"; +import type { StudioHooks } from "./studio-command.js"; import { runValidateWatch } from "./validate-watch.js"; import type { ValidateWatchHooks } from "./validate-watch.js"; -import { runValidate, runView } from "./validate-view-command.js"; +import { runValidate, runValidateRules, runView } from "./validate-view-command.js"; /** * @sdpAnchor impl:protocol.adopter-help @@ -35,11 +37,12 @@ Usage: sdp --help sdp --version sdp build [root] [--exclude PATH]... [--check-clean] [--imports PATH] - sdp validate [root] [--exclude PATH]... [--check-clean | --watch] + sdp validate [root] [--exclude PATH]... [--check-clean | --watch] [--rules PATH [--params FORM]...]... sdp view [root] [--exclude PATH]... [--check-clean] sdp census [root] [--exclude PATH]... [--check-clean] sdp mermaid [root] [--exclude PATH]... [--check-clean] sdp gherkin [root] [--exclude PATH]... [--check-clean] + sdp studio [root] [--exclude PATH]... [--check-clean] sdp import <path...> [--dry-run] sdp new spec PATH --id ID --kind KIND --altitude ALT --title TITLE --outcome OUTCOME sdp q ['<body>'] [--root PATH] [--exclude PATH]... [--params JSON | --params @PATH | --params KEY=@PATH]... [--json] @@ -67,7 +70,12 @@ Commands: projection; check errors describe the repo's conformance, not the artifact. --watch re-runs the same validate path on carrier create/change/delete/rename and stays alive after findings; operator stop (SIGINT) exits 0. --watch cannot - be combined with --check-clean. + be combined with --check-clean. --rules PATH runs an operator-selected JavaScript + async body with g, graph, report and params after the Protocol checks. Repeat --rules; + each --params belongs to the preceding file, with the q grammar: JSON, @PATH or + KEY=@PATH. Each file receives its own copies. Corpus findings print separately; + any corpus error or failed file exits 1. --watch rereads rules on each carrier cycle; + editing a rules file alone starts no cycle. --rules combines with --check-clean. view validate, then generate the Design Review — the contextual read-only human view — into <root>/generated/design-review/ (rewritten wholesale). Findings remain data; exit code follows validate. --check-clean independently re-renders. @@ -81,6 +89,9 @@ Commands: <root>/generated/gherkin/ as a separate wholesale tmp-to-rename projection. The pages are disposable and never use .sdp.gherkin. --check-clean independently re-renders and refuses when the published bytes differ from the current projection. + studio validate, then publish static Studio pages into <root>/generated/spec-studio/. + Findings remain visible; exit code follows validate. --check-clean independently + re-renders and refuses missing or drifted output. Pages open through file://. import Convert one or more *.sdp.ts files or recursively scanned roots to write-beside *.sdp.md documents. The TypeScript source is never deleted. --dry-run writes each would-be document to stdout, headed by its target path, without writing. @@ -135,7 +146,8 @@ Agent skills and reference: /** Reads one file the package ships, by URL; `sdp --version` reads through it. */ export type EngineFileReader = (url: URL) => string; -interface CliHooks extends CensusHooks, MermaidHooks, GherkinViewHooks, ValidateWatchHooks { +interface CliHooks + extends CensusHooks, MermaidHooks, GherkinViewHooks, ValidateWatchHooks, StudioHooks { readonly import?: ImportHooks; readonly query?: QueryHooks; readonly readEngineFile?: EngineFileReader; @@ -199,8 +211,8 @@ export function readEngineProvenance(read: EngineFileReader = readShippedFile): */ /** - * Every verb but `q` and `validate --watch` completes synchronously. `q` awaits an - * operator-supplied async body; `validate --watch` stays open until abort. The dispatcher's return + * The query and corpus-rule branches await operator-supplied async bodies; + * `validate --watch` stays open until abort. Other branches complete synchronously. The dispatcher's return * type carries those asynchronous branches rather than making every caller of a synchronous verb * await a resolved promise. */ @@ -230,6 +242,7 @@ export function runSdpCli( command !== "census" && command !== "mermaid" && command !== "gherkin" && + command !== "studio" && command !== "import" && command !== "new" && command !== "q" @@ -298,7 +311,9 @@ export function runSdpCli( if (command === "validate") { return parsed.watch ? runValidateWatch(parsed, output, hooks, hooks) - : runValidate(parsed, output, "validate", hooks).exitCode; + : parsed.rules === undefined + ? runValidate(parsed, output, "validate", hooks).exitCode + : runValidateRules(parsed, output, hooks).then((result) => result.exitCode); } if (command === "view") { @@ -309,6 +324,10 @@ export function runSdpCli( return runCensus(parsed, output, hooks); } + if (command === "studio") { + return runStudio(parsed, output, hooks); + } + return command === "mermaid" ? runMermaid(parsed, output, hooks) : runGherkinView(parsed, output, hooks); diff --git a/src/cli/studio-command.ts b/src/cli/studio-command.ts new file mode 100644 index 00000000..186da326 --- /dev/null +++ b/src/cli/studio-command.ts @@ -0,0 +1,80 @@ +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { createStudioData } from "../projections/studio/data.js"; +import { renderStudio } from "../projections/studio/pages/index.js"; +import { + publishStudio, + readPublishedStudio, + studioFiles, + studioFilesEqual, +} from "../projections/studio/publish.js"; +import type { StudioAssets, StudioFile } from "../projections/studio/publish.js"; +import { createReader } from "../reader/reader.js"; +import { removeArtifacts } from "./artifacts.js"; +import type { BuildArgs } from "./build-args.js"; +import { errorMessage, writeStderr, writeStdout } from "./output.js"; +import type { CliOutput } from "./output.js"; +import { runValidate } from "./validate-view-command.js"; +import type { ValidationViewHooks } from "./validate-view-command.js"; + +export type { StudioAssets } from "../projections/studio/publish.js"; + +export interface StudioHooks extends ValidationViewHooks { + readonly studioAssets?: StudioAssets; + readonly renderStudio?: typeof renderStudio; +} + +/** + * @sdpAnchor impl:protocol.studio-command + * @sdpLabel validates and renders the Studio with independent byte checks and diagnostic exit codes + * @sdpSatisfies spec:consumers.spec-studio.publisher + * @sdpComponent component:protocol.cli + * @sdpRole publisher + */ +export function runStudio(parsed: BuildArgs, output: CliOutput, hooks: StudioHooks = {}): number { + const studioPath = join(parsed.root, "generated", "spec-studio"); + const temporaryPath = `${studioPath}.tmp`; + const recoveryRm = hooks.rmSync ?? rmSync; + const fail = (message: string): number => { + writeStderr(output, message); + removeArtifacts([studioPath, temporaryPath], output, "studio", recoveryRm); + return 1; + }; + let prior: readonly StudioFile[] | undefined; + try { + if (parsed.checkClean) prior = readPublishedStudio(studioPath); + } catch (error) { + return fail( + `sdp studio --check-clean: cannot read generated Studio (${errorMessage(error)}).\n`, + ); + } + const validated = runValidate(parsed, output, "studio", hooks); + if (validated.graph === undefined) { + removeArtifacts([studioPath, temporaryPath], output, "studio", recoveryRm); + return validated.exitCode; + } + try { + const render = hooks.renderStudio ?? renderStudio; + const assets = hooks.studioAssets ?? { + script: fileURLToPath(new URL("../studio/studio.js", import.meta.url)), + stylesheet: fileURLToPath(new URL("../studio/studio.css", import.meta.url)), + }; + const files = studioFiles(render(createStudioData(createReader(validated.graph))), assets); + if (parsed.checkClean) { + const twin = studioFiles(render(createStudioData(createReader(validated.graph))), assets); + if (!studioFilesEqual(files, twin)) + return fail("sdp studio --check-clean: two independent renders diverged.\n"); + if (prior === undefined || !studioFilesEqual(prior, files)) + return fail( + "sdp studio --check-clean: generated Studio differs from the current projection.\n", + ); + } + publishStudio(studioPath, files); + writeStdout(output, `Wrote ${studioPath} (${String(files.length)} files)\n`); + return validated.exitCode; + } catch (error) { + return fail(`sdp studio: ${errorMessage(error)}\n`); + } +} diff --git a/src/cli/validate-view-command.ts b/src/cli/validate-view-command.ts index 63c2b7f6..e1dc4d98 100644 --- a/src/cli/validate-view-command.ts +++ b/src/cli/validate-view-command.ts @@ -5,11 +5,13 @@ import { renderDesignReview } from "../projections/design-review.js"; import { createReader } from "../reader/reader.js"; import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; import { codeAnchor } from "../model/code-anchor.js"; +import type { CorpusValidationReport, Finding } from "../validate/contracts.js"; import { validateGraph } from "../validate/validators.js"; import { removeArtifacts } from "./artifacts.js"; import type { BuildArgs } from "./build-args.js"; import { runBuild } from "./build-command.js"; import type { BuildHooks, BuildOutcome } from "./build-command.js"; +import { runCorpusRules } from "./corpus-rules.js"; import type { CliOutput } from "./output.js"; import { errorMessage, formatFinding, writeStderr, writeStdout } from "./output.js"; @@ -27,6 +29,17 @@ const validateSummaryAnchor = codeAnchor({ }); void validateSummaryAnchor; +function writeProtocolValidation(output: CliOutput, findings: readonly Finding[]): number { + for (const finding of findings) writeStderr(output, formatFinding(finding)); + const errorCount = findings.filter((finding) => finding.severity === "error").length; + const warningCount = findings.length - errorCount; + writeStdout( + output, + `validate: ${String(errorCount)} errors · ${String(warningCount)} warnings (conformance + honesty over the one graph)\n`, + ); + return errorCount; +} + export function runValidate( parsed: BuildArgs, output: CliOutput, @@ -44,16 +57,7 @@ export function runValidate( try { const findings = runValidateGraph(build.graph).findings; - for (const finding of findings) { - writeStderr(output, formatFinding(finding)); - } - - const errorCount = findings.filter((finding) => finding.severity === "error").length; - const warningCount = findings.length - errorCount; - writeStdout( - output, - `validate: ${String(errorCount)} errors · ${String(warningCount)} warnings (conformance + honesty over the one graph)\n`, - ); + const errorCount = writeProtocolValidation(output, findings); return { exitCode: errorCount > 0 ? 1 : 0, graph: build.graph }; } catch (error) { @@ -63,6 +67,59 @@ export function runValidate( } } +export interface CorpusValidationOutcome extends BuildOutcome { + readonly report?: CorpusValidationReport; +} + +/** + * @sdpAnchor impl:protocol.corpus-rules + * @sdpLabel runs copied rule bodies after Protocol validation and composes the separate corpus report, output and exit + * @sdpSatisfies spec:validation.corpus-rules + * @sdpComponent component:protocol.cli + * @sdpRole service + */ + +/** The async validate branch; projection publishers keep the synchronous Protocol-only path. */ +export async function runValidateRules( + parsed: BuildArgs, + output: CliOutput, + hooks: ValidationViewHooks = {}, +): Promise<CorpusValidationOutcome> { + const build = runBuild(parsed, output, "validate", hooks); + if (build.graph === undefined) { + writeStdout(output, "corpus rules: not run, the graph did not derive\n"); + return { ...build, exitCode: 1 }; + } + try { + const protocol = (hooks.validateGraph ?? validateGraph)(build.graph); + const errors = writeProtocolValidation(output, protocol.findings); + const report = await runCorpusRules(build.graph, protocol, parsed.rules ?? []); + for (const { rulesFile } of parsed.rules ?? []) { + const failed = report.corpusFailures.find((row) => row.rulesFile === rulesFile); + if (failed !== undefined) + writeStderr(output, `corpus rules ${rulesFile} failed: ${failed.reason}\n`); + else { + writeStderr(output, `corpus rules ${rulesFile}\n`); + for (const finding of report.corpusFindings.filter((row) => row.rulesFile === rulesFile)) + writeStderr(output, formatFinding(finding)); + } + } + const corpusErrors = report.corpusFindings.filter((row) => row.severity === "error").length; + writeStdout( + output, + `corpus rules: ${String(corpusErrors)} errors · ${String(report.corpusFindings.length - corpusErrors)} warnings · ${String(report.corpusFailures.length)} of ${String(parsed.rules?.length ?? 0)} files failed (the corpus's own rules)\n`, + ); + return { + graph: build.graph, + report, + exitCode: errors > 0 || corpusErrors > 0 || report.corpusFailures.length > 0 ? 1 : 0, + }; + } catch (error) { + writeStderr(output, `sdp validate: ${errorMessage(error)}\n`); + return { exitCode: 1 }; + } +} + const wholesaleViewRewriteAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.wholesale-view-rewrite"), label: "publishes each Design Review as one wholesale temporary-directory replacement", diff --git a/src/cli/validate-watch.ts b/src/cli/validate-watch.ts index beaf664f..35abfa94 100644 --- a/src/cli/validate-watch.ts +++ b/src/cli/validate-watch.ts @@ -6,7 +6,7 @@ import { isExcludedDiscoveryDirectory } from "../extract/discover.js"; import type { BuildArgs } from "./build-args.js"; import type { CliOutput } from "./output.js"; import { writeStderr } from "./output.js"; -import { runValidate } from "./validate-view-command.js"; +import { runValidate, runValidateRules } from "./validate-view-command.js"; import type { ValidationViewHooks } from "./validate-view-command.js"; const CARRIER_SUFFIXES = [".sdp.ts", ".sdp.md", ".sdp.gherkin"] as const; @@ -514,6 +514,10 @@ export async function runValidateWatch( const onAbort = (): void => { close(); + if (watchHooks.abortSignal === undefined) { + // An in-process rules body cannot be cancelled and may keep live handles indefinitely. + process.exit(0); + } }; if (watchHooks.abortSignal === undefined) { @@ -575,7 +579,10 @@ export async function runValidateWatch( await watchHooks.watchCycleGate?.(state.cycleIndex); if (!isWatchClosed(state)) { - const outcome = runValidate(parsed, output, "validate", hooks); + const outcome = + parsed.rules === undefined + ? runValidate(parsed, output, "validate", hooks) + : await runValidateRules(parsed, output, hooks); watchHooks.onWatchCycleComplete?.({ index: state.cycleIndex, exitCode: outcome.exitCode, diff --git a/src/projections/studio/data.ts b/src/projections/studio/data.ts new file mode 100644 index 00000000..c90d2c94 --- /dev/null +++ b/src/projections/studio/data.ts @@ -0,0 +1,306 @@ +import type { GraphNodeType } from "../../graph/schema.js"; +import type { PackContext, Reader, SpecContext } from "../../reader/reader.js"; +import type { Finding } from "../../validate/contracts.js"; +import { pagePath } from "./paths.js"; + +export interface PackTotals { + readonly members: number; + readonly unresolved: number; + readonly heldBelowNextRung: number; + readonly waitingForAuthor: number; + readonly entries: number; + readonly declarations: number; + readonly openQuestions: number; + readonly blockingQuestions: number; + readonly withImplementations: number; + readonly withReferences: number; + readonly restsOn: number; + readonly restedOnBy: number; + readonly verifiedBy: number; + readonly supersededBy: number; +} + +export interface RailValues { + readonly packs: readonly { + readonly id: string; + readonly title?: string; + readonly members: readonly { + readonly id: string; + readonly title?: string; + readonly statedReadiness?: string; + readonly ownsExampleSpace: boolean; + }[]; + }[]; + readonly findings: { readonly errors: number; readonly warnings: number }; +} + +export interface SearchEntry { + readonly section: string; + readonly entry: string | null; + readonly key: string | null; + readonly address: string | null; + readonly line: number | null; + readonly text: string; +} + +export interface SearchRecord { + readonly id: string; + readonly nodeType: GraphNodeType; + readonly page: string | null; + readonly title?: string; + readonly label?: string; + readonly framing?: string; + readonly narrative?: string; + readonly sections?: Record<string, unknown>; + readonly file?: string; + readonly line?: number; + readonly specKind?: string; + readonly statedReadiness?: string; + readonly floorReached?: string | null; + readonly bindings: readonly { readonly edge: string; readonly specId: string }[]; + readonly entries: readonly SearchEntry[]; +} + +export interface SearchIndex { + readonly records: readonly SearchRecord[]; +} + +export interface StudioData { + readonly specs: readonly SpecContext[]; + readonly packs: readonly { readonly context: PackContext; readonly totals: PackTotals }[]; + readonly findings: readonly Finding[]; + readonly rail: RailValues; + readonly search: SearchIndex; +} + +/** + * @sdpAnchor impl:protocol.studio-data + * @sdpLabel assembles reader contexts, Pack totals, rails and search records for static pages + * @sdpSatisfies spec:consumers.spec-studio.data + * @sdpComponent component:protocol.projections + * @sdpRole projection + */ +export function packTotals(pack: PackContext): PackTotals { + const resolved = pack.members.filter((member) => member.resolved); + return { + members: pack.members.length, + unresolved: pack.members.length - resolved.length, + heldBelowNextRung: resolved.filter((member) => (member.statedNextRungFailures?.length ?? 0) > 0) + .length, + waitingForAuthor: resolved.filter( + (member) => + member.statedNextRung !== undefined && member.statedNextRungFailures?.length === 0, + ).length, + entries: resolved.reduce((sum, member) => sum + (member.design?.entries ?? 0), 0), + declarations: resolved.reduce((sum, member) => sum + (member.design?.declarations ?? 0), 0), + openQuestions: resolved.reduce( + (sum, member) => sum + (member.design?.openQuestions.length ?? 0), + 0, + ), + blockingQuestions: resolved.reduce( + (sum, member) => sum + (member.design?.blockingQuestions ?? 0), + 0, + ), + withImplementations: resolved.filter((member) => (member.implementations?.length ?? 0) > 0) + .length, + withReferences: resolved.filter((member) => (member.references?.length ?? 0) > 0).length, + restsOn: pack.boundary.restsOn.length, + restedOnBy: pack.boundary.restedOnBy.length, + verifiedBy: pack.boundary.verifiedBy.length, + supersededBy: pack.boundary.supersededBy.length, + }; +} + +export function railValues(reader: Reader): RailValues { + return { + packs: reader.packs().map((pack) => ({ + id: pack.id, + ...(pack.title === undefined ? {} : { title: pack.title }), + members: (reader.packContext(pack.id)?.members ?? []).map((member) => ({ + id: member.id, + ...(member.title === undefined ? {} : { title: member.title }), + ...(member.statedReadiness === undefined + ? {} + : { statedReadiness: member.statedReadiness }), + ownsExampleSpace: + reader.specContext(member.id)?.sections?.behavior?.exampleSpace !== undefined, + })), + })), + findings: { + errors: reader.findings().filter((finding) => finding.severity === "error").length, + warnings: reader.findings().filter((finding) => finding.severity === "warning").length, + }, + }; +} + +/** Enumerates recipe 23's walk without a term; matching remains the browser matcher's job. */ +function searchEntries(context: SpecContext): readonly SearchEntry[] { + const entries: SearchEntry[] = []; + const questions = context.sections?.intent?.openQuestions; + const questionKeyOf = (entry: string | null): string | null => { + const index = /^openQuestions\[(\d+)\]\.question$/u.exec(entry ?? "")?.[1]; + const question = index === undefined ? undefined : questions?.[Number(index)]; + const key = typeof question === "object" ? question.key : undefined; + return typeof key === "string" && /^[a-z][A-Za-z0-9]*$/u.test(key) ? key : null; + }; + const ruleKeys = context.sections?.behavior?.ruleKeys ?? {}; + const ruleKeyOf = (entry: string | null): string | null => + Object.keys(ruleKeys).find((key) => ruleKeys[key] === entry) ?? null; + const addressOf = (section: string, entry: string | null): string | null => { + if (section === "behavior" || section === "intent") { + const key = section === "behavior" ? ruleKeyOf(entry) : questionKeyOf(entry); + return key === null + ? null + : `${context.id}#${section === "behavior" ? "rule" : "question"}.${key}`; + } + return (section === "design" || section === "ui") && + entry !== null && + entry !== "description" && + /^[a-z][A-Za-z0-9]*$/u.test(entry) + ? `${context.id}#${section}.${entry}` + : null; + }; + const lines = new Map(context.entryLocations.map((location) => [location.entry, location.line])); + const lineOf = (section: string, entry: string | null, topKey: string | null): number | null => { + const question = + /^openQuestions\[(\d+)\](?:\.(question|decider|links|provisional|trigger)(?:\[\d+\])?)?$/u.exec( + entry ?? "", + ); + const rule = /^(rules|flows)\[(\d+)\]$/u.exec(entry ?? ""); + const located = + section === "intent" && question?.[1] !== undefined + ? `question[${question[1]}]${question[2] !== undefined && question[2] !== "question" ? `.${question[2]}` : ""}` + : section === "behavior" && rule !== null + ? `${rule[1] === "rules" ? "rule" : "flow"}[${rule[2] ?? ""}]` + : (section === "design" || section === "ui") && topKey !== null + ? `${section}.${topKey}` + : null; + return located === null ? null : (lines.get(located) ?? null); + }; + const walk = ( + section: string, + value: unknown, + entry: string | null, + key: string | null, + topKey: string | null, + ): void => { + if (typeof value === "string" || key !== null) { + entries.push({ + section, + entry, + key, + address: addressOf(section, entry), + line: lineOf(section, entry, topKey), + text: typeof value === "string" ? value : JSON.stringify(value), + }); + } + if (Array.isArray(value)) { + value.forEach((item: unknown, index) => { + const path = `${entry ?? ""}[${String(index)}]`; + walk(section, item, path, section === "behavior" ? ruleKeyOf(path) : null, null); + }); + } else if (typeof value === "object" && value !== null) { + const coined = + section === "design" || section === "ui" || (section === "model" && entry === "terms"); + for (const [name, item] of Object.entries(value)) { + const path = entry === null ? name : `${entry}.${name}`; + if (section === "behavior" && entry === null && name === "ruleKeys") continue; + if (section === "intent" && /^openQuestions\[\d+\]\.key$/u.test(path)) continue; + walk( + section, + item, + path, + coined && !(entry === null && name === "description") + ? name + : section === "intent" + ? questionKeyOf(path) + : null, + entry === null ? name : null, + ); + } + } + }; + if (context.narrative !== undefined) walk("narrative", context.narrative, null, null, null); + for (const [section, value] of Object.entries(context.sections ?? {})) + walk(section, value, null, null, null); + return entries; +} + +export function searchIndex(reader: Reader): SearchIndex { + const specs = new Map(reader.specs().map((spec) => [spec.id, spec])); + const bindings = new Map<string, { edge: string; specId: string }[]>(); + for (const edge of reader.graph.edges) { + if (!specs.has(edge.to)) continue; + const rows = bindings.get(edge.from) ?? []; + rows.push({ edge: edge.type, specId: edge.to }); + bindings.set(edge.from, rows); + } + return { + records: [...reader.graph.nodes] + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + .map((node) => { + const common = { + id: node.id, + nodeType: node.nodeType, + page: pagePath(node.id), + file: node.file, + }; + if (node.nodeType === "Primitive") { + const spec = specs.get(node.id); + return { + ...common, + ...(node.title === undefined ? {} : { title: node.title }), + ...(node.narrative === undefined ? {} : { narrative: node.narrative }), + ...(node.sections === undefined + ? {} + : { sections: node.sections as Record<string, unknown> }), + specKind: node.specKind, + statedReadiness: node.readiness, + floorReached: spec?.derivedReadiness ?? null, + bindings: [], + entries: searchEntries(specContext(reader, node.id)), + }; + } + if (node.nodeType === "Pack") + return { + ...common, + ...(node.title === undefined ? {} : { title: node.title }), + ...(node.framing === undefined ? {} : { framing: node.framing }), + bindings: [], + entries: [], + }; + return { + ...common, + ...(node.label === undefined ? {} : { label: node.label }), + ...(node.line === undefined ? {} : { line: node.line }), + bindings: bindings.get(node.id) ?? [], + entries: [], + }; + }), + }; +} + +/** JSON text for the one application/json element, safe even for carrier text containing tags. */ +export function embedData(value: unknown): string { + return JSON.stringify(value).replaceAll("<", "\\u003c"); +} + +function specContext(reader: Reader, id: string): SpecContext { + const context = reader.specContext(id); + if (context === undefined) throw new Error(`Studio cannot resolve Spec ${id}`); + return context; +} + +export function createStudioData(reader: Reader): StudioData { + return { + specs: reader.specs().map((spec) => specContext(reader, spec.id)), + packs: reader.packs().map((pack) => { + const context = reader.packContext(pack.id); + if (context === undefined) throw new Error(`Studio cannot resolve Pack ${pack.id}`); + return { context, totals: packTotals(context) }; + }), + findings: reader.findings(), + rail: railValues(reader), + search: searchIndex(reader), + }; +} diff --git a/src/projections/studio/pages/code-spans.ts b/src/projections/studio/pages/code-spans.ts new file mode 100644 index 00000000..39b677d2 --- /dev/null +++ b/src/projections/studio/pages/code-spans.ts @@ -0,0 +1,47 @@ +export function escapeHtml(value: string): string { + return value.replace( + /[&<>"']/gu, + (char) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char] ?? char, + ); +} + +/** + * Shares the delimiter walk between prose, titles and the browser's result headings. + * @sdpAnchor impl:protocol.studio-code-spans + * @sdpSatisfies spec:consumers.spec-studio.shell + * @sdpComponent component:protocol.projections + * @sdpRole codec + */ +export function mapCodeSpans( + value: string, + plain: (value: string) => string, + span: (value: string) => string, +): string { + const runs = [...value.matchAll(/`+/gu)]; + let at = 0; + let out = ""; + for (let i = 0; i < runs.length; i++) { + const open = runs[i]; + if (!open) continue; + const closing = runs.findIndex((run, j) => j > i && run[0].length === open[0].length); + if (closing < 0) continue; + const close = runs[closing]; + if (!close) continue; + out += plain(value.slice(at, open.index)); + out += span(value.slice(open.index + open[0].length, close.index)); + at = close.index + close[0].length; + i = closing; + } + return out + plain(value.slice(at)); +} + +export const codeSpans = (value: string): string => + mapCodeSpans(value, escapeHtml, (v) => `<code>${escapeHtml(v)}</code>`); + +export const plainTitle = (value: string): string => + mapCodeSpans( + value, + (v) => v, + (v) => v, + ); diff --git a/src/projections/studio/pages/frame.ts b/src/projections/studio/pages/frame.ts new file mode 100644 index 00000000..80896314 --- /dev/null +++ b/src/projections/studio/pages/frame.ts @@ -0,0 +1,49 @@ +import { pagePath } from "../paths.js"; +import { count, escape, link, relative } from "./html.js"; +import type { PageContext } from "./html.js"; +import { codeSpans, plainTitle } from "./code-spans.js"; + +/** + * Writes the shared navigation and the rail appropriate to the selected graph node. + * @sdpAnchor impl:protocol.studio-frame + * @sdpSatisfies spec:consumers.spec-studio.shell + * @sdpReferences spec:consumers.spec-studio, spec:consumers.spec-studio.responsive, spec:consumers.spec-studio.lenses + * @sdpComponent component:protocol.projections + * @sdpRole projection + */ +export function frame(ctx: PageContext, title: string, body: string, selected?: string): string { + const prefix = relative(ctx, "index.html").replace(/index\.html$/u, ""); + const isPack = selected?.startsWith("pack:") ?? false; + const selectedSpec = ctx.data.specs.find((spec) => spec.id === selected); + const packs = isPack + ? ctx.data.rail.packs.filter((pack) => pack.id === selected) + : selectedSpec + ? ctx.data.rail.packs.filter((pack) => selectedSpec.packs.includes(pack.id)) + : ctx.data.rail.packs; + const packRail = packs + .map( + (pack) => + `<h2>${link(ctx, pack.id, pack.title ?? pack.id, codeSpans(pack.title ?? pack.id))}</h2>${ + isPack || selectedSpec + ? `<ol>${pack.members + .map((member) => { + const path = pagePath(member.id); + const resolved = ctx.data.specs.some((s) => s.id === member.id); + const label = codeSpans(member.title ?? member.id); + return `<li>${isPack ? `<a href="#${escape(member.id)}">${label}</a>` : path !== null && resolved ? `<a href="${escape(relative(ctx, path))}"${member.id === selected ? ' aria-current="page"' : ""}>${label}</a>` : label}</li>`; + }) + .join("")}</ol>` + : "" + }`, + ) + .join(""); + const exampleSpaces = [ + ...new Map( + packs.flatMap((pack) => + pack.members.filter((m) => m.ownsExampleSpace).map((m) => [m.id, m] as const), + ), + ).values(), + ]; + const rail = `${packRail}${(!isPack && !selectedSpec) || exampleSpaces.length === 0 ? "" : `<h2>Example spaces</h2><ul>${exampleSpaces.map((m) => `<li>${link(ctx, m.id, m.title ?? m.id, codeSpans(m.title ?? m.id))}</li>`).join("")}</ul>`}<h2>Findings</h2><p><a href="${prefix}index.html#findings">${count(ctx.data.rail.findings.errors, "error")} · ${count(ctx.data.rail.findings.warnings, "warning", "warnings", false)}</a></p>`; + return `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title === "Spec Studio" ? "Spec Studio" : `${escape(plainTitle(title))} · Spec Studio`}
Spec Studio
${body}
\n`; +} diff --git a/src/projections/studio/pages/html.ts b/src/projections/studio/pages/html.ts new file mode 100644 index 00000000..01d3f4f6 --- /dev/null +++ b/src/projections/studio/pages/html.ts @@ -0,0 +1,105 @@ +import { posix } from "node:path"; +import type { StudioData } from "../data.js"; +import { pagePath } from "../paths.js"; +import type { Finding } from "../../../validate/contracts.js"; +import type { CodeUnitBinding, PackMemberQuestion } from "../../../reader/reader.js"; + +export interface PageContext { + readonly data: StudioData; + readonly path: string; +} + +export function escape(value: unknown): string { + return ( + value === null || value === undefined + ? "" + : typeof value === "string" + ? value + : JSON.stringify(value) + ) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} +export const code = (value: unknown): string => `${escape(value)}`; +export const copy = (value: string): string => ``; +export const location = (file?: string, line?: number): string => + file === undefined + ? "location not recorded" + : `${file}${line === undefined ? "" : `:${String(line)}`}`; +export const present = (value: boolean): string => (value ? "present" : "none"); +export const count = ( + n: number, + singular: string, + plural = `${singular}s`, + opening = true, +): string => `${n === 0 ? (opening ? "No" : "no") : String(n)} ${n === 1 ? singular : plural}`; +/** The verb that agrees with a count: `rests` after one, `rest` after any other count. */ +export const verb = (n: number, plural: string, singular = `${plural}s`): string => + n === 1 ? singular : plural; +export const badge = (rung?: string): string => + rung === undefined + ? "stated rung not recorded" + : `${escape(rung)}`; +export const rungLine = (stated?: string, floor?: string): string => + `${badge(stated)} floor ${escape(floor ?? "none")}`; +export const list = (rows: readonly string[], className = "plain-list"): string => + rows.length === 0 + ? "

None.

" + : `
    ${rows.map((row) => `
  • ${row}
  • `).join("")}
`; +export const relative = (ctx: PageContext, path: string): string => + posix.relative(posix.dirname(ctx.path), path); + +export function link(ctx: PageContext, id: string, label = id, htmlLabel = escape(label)): string { + const [base = "", fragment] = id.split("#"); + const path = pagePath(base); + const spec = ctx.data.specs.find((s) => s.id === base); + const pack = ctx.data.packs.find((p) => p.context.id === base); + if (path === null || (!spec && !pack)) return htmlLabel; + if ( + fragment !== undefined && + !ctx.data.search.records.find((r) => r.id === base)?.entries.some((e) => e.address === id) + ) + return htmlLabel; + return `${htmlLabel}`; +} + +export function findingList(ctx: PageContext, rows: readonly Finding[]): string { + return list( + rows.map( + (f) => + `${escape(f.severity)} · ${code(f.validatorId)}${f.subjectId === undefined ? "" : ` · ${link(ctx, f.subjectId)}`} · ${escape(f.message)}${f.file === undefined ? "" : ` · ${code(location(f.file, f.line))}`}`, + ), + ); +} + +export function unitList(rows: readonly CodeUnitBinding[]): string { + return list( + rows.map( + (unit) => + `${code(unit.codeId)}${unit.label === undefined ? "" : ` · ${escape(unit.label)}`} · ${code(location(unit.file, unit.line))}${unit.file === undefined ? "" : copy(location(unit.file, unit.line))}${unit.role === undefined ? "" : ` · role ${escape(unit.role)}`}${unit.component === undefined ? "" : ` · component ${code(unit.component.id)}${unit.component.layer === undefined ? "" : ` · layer ${escape(unit.component.layer)}`}${unit.component.context === undefined ? "" : ` · context ${escape(unit.component.context)}`}`}${unit.layer === undefined ? "" : ` · layer ${escape(unit.layer)}`}${unit.context === undefined ? "" : ` · context ${escape(unit.context)}`}`, + ), + "units", + ); +} + +export function questionFields( + ctx: PageContext, + q: PackMemberQuestion, + text: (value: string) => string, +): string { + return [ + q.decider === undefined + ? "" + : `Decider: ${escape(q.decider)}`, + q.links === undefined + ? "" + : `Links: ${q.links.map((id) => link(ctx, id)).join(", ")}`, + q.provisional === undefined + ? "" + : `Provisional: ${text(q.provisional)}`, + q.trigger === undefined ? "" : `Trigger: ${text(q.trigger)}`, + ].join(""); +} diff --git a/src/projections/studio/pages/index-page.ts b/src/projections/studio/pages/index-page.ts new file mode 100644 index 00000000..f30bd52c --- /dev/null +++ b/src/projections/studio/pages/index-page.ts @@ -0,0 +1,99 @@ +import { SPEC_READINESS } from "../../../model/descriptors.js"; +import { badge, code, count, escape, findingList, link, present, rungLine } from "./html.js"; +import type { PageContext } from "./html.js"; +import { frame } from "./frame.js"; +import { codeSpans } from "./text.js"; + +/** + * Counts reader values and writes native Pack disclosures and filterable Spec rows. + * @sdpAnchor impl:protocol.studio-index-page + * @sdpSatisfies spec:consumers.spec-studio.index-page + * @sdpComponent component:protocol.projections + * @sdpRole projection + */ +export function indexPage(ctx: PageContext): string { + const { specs, packs, findings } = ctx.data; + const divergence = specs.filter( + (s) => + SPEC_READINESS.indexOf(s.statedReadiness) > + (s.derivedReadiness === undefined ? -1 : SPEC_READINESS.indexOf(s.derivedReadiness)), + ).length; + const summary = [ + `${count(specs.length, "Spec")} and ${count(packs.length, "Pack", "Packs", false)} in this corpus.`, + ...SPEC_READINESS.map( + (rung) => + `${count(specs.filter((s) => s.statedReadiness === rung).length, "Spec")} stated ${rung}.`, + ), + `${count(divergence, "Spec")} stated above ${divergence === 1 ? "its" : "their"} floor.`, + `${count( + specs.reduce((n, s) => n + s.designColumns.openQuestions.length, 0), + "open question", + )} and ${count( + specs.reduce((n, s) => n + s.designColumns.blockingQuestions, 0), + "blocking question", + "blocking questions", + false, + )}.`, + `${count(specs.filter((s) => s.implementations.length > 0).length, "Spec")} with an implementation binding and ${count(specs.filter((s) => s.deliveryFacts.includes("has-verifier")).length, "Spec", "Specs", false)} with a verifier binding.`, + `${count(findings.filter((f) => f.severity === "error").length, "error")} and ${count(findings.filter((f) => f.severity === "warning").length, "warning", "warnings", false)}.`, + ]; + const rowValues = specs.map((spec) => ({ + kind: spec.specKind, + altitude: spec.altitude, + stated: spec.statedReadiness, + floor: spec.derivedReadiness ?? "none", + implementation: present(spec.implementations.length > 0), + verifier: present(spec.deliveryFacts.includes("has-verifier")), + pack: spec.packs.join(" "), + })); + const labels = { + kind: "Kind", + altitude: "Altitude", + stated: "Stated rung", + floor: "Floor reached", + implementation: "Implementation binding", + verifier: "Verifier binding", + pack: "Pack", + }; + const filters = Object.entries(labels) + .map(([name, label]) => { + const values = [ + ...new Set( + rowValues.flatMap((row) => + (row[name as keyof typeof row] || "").split(" ").filter(Boolean), + ), + ), + ].sort((a, b) => + name === "stated" || name === "floor" + ? [...SPEC_READINESS, "none"].indexOf(a) - [...SPEC_READINESS, "none"].indexOf(b) + : a < b + ? -1 + : a > b + ? 1 + : 0, + ); + return ``; + }) + .join(""); + const specRows = specs + .map( + (spec, i) => + `
  • `data-${name}="${escape(value)}"`) + .join( + " ", + )}>${link(ctx, spec.id, spec.title ?? spec.id, codeSpans(spec.title ?? spec.id))} · ${code(spec.id)} · ${rungLine(spec.statedReadiness, spec.derivedReadiness)} · ${escape(spec.specKind)} · ${escape(spec.altitude)} · implementation binding ${present(spec.implementations.length > 0)} · verifier binding ${present(spec.deliveryFacts.includes("has-verifier"))} · ${String(spec.designColumns.openQuestions.length)} open, ${String(spec.designColumns.blockingQuestions)} blocking questions · Packs ${spec.packs.length === 0 ? "none" : spec.packs.map((id) => link(ctx, id)).join(", ")}
  • `, + ) + .join(""); + const packList = packs + .map( + ({ context: pack }) => + `
    ${codeSpans(pack.title ?? pack.id)} · ${code(pack.id)} · ${count(pack.members.length, "member")}

    ${link(ctx, pack.id, "Read the Pack page")}

      ${pack.members.map((m) => `
    1. ${link(ctx, m.id, m.title ?? m.id, codeSpans(m.title ?? m.id))} · ${m.resolved ? `${badge(m.statedReadiness)} · floor ${escape(m.derivedReadiness ?? "none")}` : "unresolved member"}
    2. `).join("")}
    `, + ) + .join(""); + return frame( + ctx, + "Spec Studio", + `

    Spec Studio

      ${summary.map((s) => `
    • ${s}
    • `).join("")}

    Start here

    Packs

    ${packList || "

    No Packs.

    "}

    Specs

    ${String(specs.length)} of ${String(specs.length)} shown
      ${specRows}

    Findings

    ${findingList(ctx, findings)}
    `, + ); +} diff --git a/src/projections/studio/pages/index.ts b/src/projections/studio/pages/index.ts new file mode 100644 index 00000000..09d9d7a6 --- /dev/null +++ b/src/projections/studio/pages/index.ts @@ -0,0 +1,28 @@ +import type { StudioData } from "../data.js"; +import { pagePath } from "../paths.js"; +import { indexPage } from "./index-page.js"; +import { packPage } from "./pack-page.js"; +import { specPage } from "./spec-page.js"; +import { searchPage } from "./search-page.js"; + +export interface StudioPage { + readonly path: string; + readonly content: string; +} + +export function renderStudio(data: StudioData): readonly StudioPage[] { + return [ + { path: "index.html", content: indexPage({ data, path: "index.html" }) }, + { path: "search.html", content: searchPage({ data, path: "search.html" }) }, + ...data.packs.map(({ context, totals }) => { + const path = pagePath(context.id); + if (path === null) throw new Error(`Pack has no Studio path: ${context.id}`); + return { path, content: packPage({ data, path }, context, totals) }; + }), + ...data.specs.map((spec) => { + const path = pagePath(spec.id); + if (path === null) throw new Error(`Spec has no Studio path: ${spec.id}`); + return { path, content: specPage({ data, path }, spec) }; + }), + ]; +} diff --git a/src/projections/studio/pages/pack-page.ts b/src/projections/studio/pages/pack-page.ts new file mode 100644 index 00000000..532a7b73 --- /dev/null +++ b/src/projections/studio/pages/pack-page.ts @@ -0,0 +1,114 @@ +import type { PackContext, PackMemberSummary } from "../../../reader/reader.js"; +import type { PackTotals } from "../data.js"; +import { + code, + copy, + count, + escape, + findingList, + link, + list, + present, + questionFields, + rungLine, + unitList, + verb, +} from "./html.js"; +import type { PageContext } from "./html.js"; +import { frame } from "./frame.js"; +import { codeSpans, prose, text } from "./text.js"; + +export function holds( + ctx: PageContext, + rung: string | undefined, + failures: PackMemberSummary["statedNextRungFailures"], +): string { + if (rung === undefined) return ""; + if (!failures?.length) + return `

    Next rung ${escape(rung)}: nothing blocks; it waits only for the author's statement.

    `; + return `

    Next rung ${escape(rung)} waits on ${count(failures.length, "unmet clause", "unmet clauses", false)}:

    ${list(failures.map((f) => `${code(f.clauseId)} · ${escape(f.description)}${f.targets?.length ? list(f.targets.map((t) => `${escape(t.type)} · ${link(ctx, t.id)} · stated ${escape(t.statedReadiness ?? "not recorded")}`)) : ""}`))}`; +} + +function memberCard(ctx: PageContext, member: PackMemberSummary, index: number): string { + const opening = `
    `; + if (!member.resolved) + return `${opening}${String(index + 1)}. ${code(member.id)}

    The manifest names an id the graph does not hold.

    `; + const design = member.design; + const next = + member.statedNextRung === undefined + ? "" + : ` · next rung ${escape(member.statedNextRung)}: ${(member.statedNextRungFailures?.length ?? 0) === 0 ? "nothing blocks" : `waits on ${count(member.statedNextRungFailures?.length ?? 0, "unmet clause", "unmet clauses", false)}`}`; + const questions = list( + (design?.openQuestions ?? []).map( + (q) => + `${q.blocking ? "blocking" : "non-blocking"}${q.key === undefined ? "" : ` · ${link(ctx, `${member.id}#question.${q.key}`, `question.${q.key}`)} ${copy(`${member.id}#question.${q.key}`)}`}${q.line === undefined ? "" : ` · line ${String(q.line)}`} · ${text(ctx, q.question)}${questionFields(ctx, q, (v) => text(ctx, v))}`, + ), + "questions", + ); + const decisions = list( + (design?.decisions ?? []).map( + (d) => + `${link(ctx, d.id, d.title ?? d.id, codeSpans(d.title ?? d.id))} · stated ${escape(d.statedReadiness ?? "not recorded")}${d.resolved ? "" : " · unresolved"}`, + ), + ); + const verifier = (member.verifiers?.enabled ?? 0) > 0; + const counts = + (member.verifiers?.total ?? 0) > 0 || (member.examples?.total ?? 0) > 0 + ? `${count(member.verifiers?.total ?? 0, "verifier")}, ${String(member.verifiers?.enabled ?? 0)} enabled · ${count(member.examples?.total ?? 0, "verifying example", "verifying examples", false)}, ${String(member.examples?.enabled ?? 0)} enabled` + : ""; + return `${opening}${String(index + 1)}. ${codeSpans(member.title ?? member.id)}${escape(member.specKind)} · ${rungLine(member.statedReadiness, member.derivedReadiness)}${next} · ${String(design?.openQuestions.length ?? 0)} open, ${String(design?.blockingQuestions ?? 0)} blocking questions · implementation binding ${present((member.implementations?.length ?? 0) > 0)} · verifier binding ${present(verifier)}

    ${link(ctx, member.id)}

    ${holds(ctx, member.statedNextRung, member.statedNextRungFailures)}

    Design

    ${count(design?.entries ?? 0, "design entry", "design entries")} · ${count(design?.declarations ?? 0, "pinned declaration", "pinned declarations", false)}

    ${design?.decisions.length ? `

    Decisions

    ${decisions}` : ""}

    Open questions

    ${questions}

    Implementation binding: ${present((member.implementations?.length ?? 0) > 0)}

    ${member.implementations?.length ? unitList(member.implementations) : ""}

    Design reference: ${present((member.references?.length ?? 0) > 0)}

    ${member.references?.length ? unitList(member.references) : ""}

    Verifier binding: ${present(verifier)}

    ${counts ? `

    ${counts}

    ` : ""}
    `; +} + +/** + * Renders reader Pack totals, ordered member disclosures and the four boundary directions. + * @sdpAnchor impl:protocol.studio-pack-page + * @sdpSatisfies spec:consumers.spec-studio.pack-page + * @sdpComponent component:protocol.projections + * @sdpRole projection + */ +export function packPage(ctx: PageContext, pack: PackContext, totals: PackTotals): string { + const held = pack.members.filter((m) => (m.statedNextRungFailures?.length ?? 0) > 0); + const waiting = pack.members.filter( + (m) => m.resolved && m.statedNextRung !== undefined && m.statedNextRungFailures?.length === 0, + ); + const cards = (rows: readonly PackMemberSummary[], includeFailures = false): string => + rows + .map( + (m) => + `${codeSpans(m.title ?? m.id)}${includeFailures ? ` (${count(m.statedNextRungFailures?.length ?? 0, "unmet clause", "unmet clauses", false)})` : ""}`, + ) + .join(", "); + const summary = [ + `${count(totals.members, "member")} in authored reading order; ${count(totals.unresolved, "unresolved member", "unresolved members", false)}.`, + `${count(totals.heldBelowNextRung, "member")} held below their stated next rung${held.length ? `: ${cards(held, true)}` : ""}.`, + `${count(totals.waitingForAuthor, "member")} waiting only for their author's statement${waiting.length && waiting.length !== pack.members.length ? `: ${cards(waiting)}` : ""}.`, + `${count(totals.entries, "design entry", "design entries")} and ${count(totals.declarations, "pinned declaration", "pinned declarations", false)}.`, + `${count(totals.openQuestions, "open question")} and ${count(totals.blockingQuestions, "blocking question", "blocking questions", false)}.`, + `${count(totals.withImplementations, "member")} with an implementation binding.`, + `${count(totals.withReferences, "member")} with a design reference.`, + `${count(totals.restsOn, "outside Spec")} the Pack rests on; ${count(totals.restedOnBy, "outside Spec", "outside Specs", false)} ${verb(totals.restedOnBy, "rest")} on the Pack; ${count(totals.verifiedBy, "outside Spec", "outside Specs", false)} ${verb(totals.verifiedBy, "verify", "verifies")} it; ${count(totals.supersededBy, "outside Spec", "outside Specs", false)} ${verb(totals.supersededBy, "supersede")} it.`, + ]; + const boundary = ( + [ + ["restsOn", "Rests on"], + ["restedOnBy", "Rested on by"], + ["verifiedBy", "Verified by"], + ["supersededBy", "Superseded by"], + ] as const + ) + .map(([key, title]) => { + const rows = pack.boundary[key]; + return `
    ${title} (${String(rows.length)})
    ${rows.length === 0 ? "

    None.

    " : `
    ${rows.map((row) => ``).join("")}
    SpecStated rungImplementation bindingJoined members
    ${link(ctx, row.id, row.title ?? row.id, codeSpans(row.title ?? row.id))}
    ${code(row.id)}
    ${escape(row.statedReadiness)}${present(row.implemented)}${row.via.map((v) => `${escape(v.type)}: ${v.members.map((id) => `${escape(id)}`).join(", ")}`).join("
    ")}
    `}
    `; + }) + .join(""); + const gaps = [...pack.verifierGaps].sort((a, b) => Number(b.priority) - Number(a.priority)); + return frame( + ctx, + pack.title ?? pack.id, + `

    ${codeSpans(pack.title ?? pack.id)}

    ${code(pack.id)}${copy(pack.id)}${code(pack.file)}${copy(pack.file)}

    Where the design stands

      ${summary.map((s) => `
    • ${s}
    • `).join("")}

    Framing

    ${prose(ctx, pack.framing)}${pack.modelRefs.length === 0 ? "" : `

    Vocabulary: ${pack.modelRefs.map((id) => link(ctx, id)).join(", ")}

    `}

    Members

    ${pack.members.map((m, i) => memberCard(ctx, m, i)).join("")}

    Boundary

    ${boundary}

    Members with no verifier binding

    The graph records bindings and never runs, so no page shows a run result. Decision records are left out: a decision never needs a verifier binding.

    ${list(gaps.map((g) => `${link(ctx, g.id, ctx.data.specs.find((s) => s.id === g.id)?.title ?? g.id, codeSpans(ctx.data.specs.find((s) => s.id === g.id)?.title ?? g.id))} · ${code(g.id)} · stated ${escape(g.statedReadiness ?? "not recorded")}`))}

    Findings

    ${findingList( + ctx, + ctx.data.findings.filter((f) => f.subjectId === pack.id), + )}
    `, + pack.id, + ); +} diff --git a/src/projections/studio/pages/search-page.ts b/src/projections/studio/pages/search-page.ts new file mode 100644 index 00000000..cb6cd6bb --- /dev/null +++ b/src/projections/studio/pages/search-page.ts @@ -0,0 +1,18 @@ +import { embedData } from "../data.js"; +import { frame } from "./frame.js"; +import type { PageContext } from "./html.js"; + +/** + * Embeds the search records beside the results element, before the deferred bundle runs. + * @sdpAnchor impl:protocol.studio-search-page + * @sdpSatisfies spec:consumers.spec-studio.search + * @sdpComponent component:protocol.projections + * @sdpRole projection + */ +export function searchPage(ctx: PageContext): string { + return frame( + ctx, + "Search", + `

    Search

    `, + ); +} diff --git a/src/projections/studio/pages/spec-page.ts b/src/projections/studio/pages/spec-page.ts new file mode 100644 index 00000000..6ce5f255 --- /dev/null +++ b/src/projections/studio/pages/spec-page.ts @@ -0,0 +1,193 @@ +import { SPEC_READINESS } from "../../../model/descriptors.js"; +import { SPEC_SECTION_NAMES } from "../../../model/sections.js"; +import type { SpecContext, RelationEnd } from "../../../reader/reader.js"; +import { + code, + copy, + count, + escape, + findingList, + link, + list, + location, + present, + questionFields, + rungLine, + unitList, +} from "./html.js"; +import type { PageContext } from "./html.js"; +import { frame } from "./frame.js"; +import { holds } from "./pack-page.js"; +import { codeSpans, prose, text, valueHtml } from "./text.js"; + +const entryLine = (spec: SpecContext, entry: string): number | undefined => + spec.entryLocations.find((loc) => loc.entry === entry)?.line; +const lineLabel = (line?: number): string => + line === undefined ? "" : ` line ${String(line)}`; + +function entry( + ctx: PageContext, + spec: SpecContext, + key: string, + value: unknown, + address?: string, + located?: string, +): string { + return `
    ${code(key)}${lineLabel(entryLine(spec, located ?? address ?? ""))}${address === undefined ? "" : copy(`${spec.id}#${address}`)}
    ${valueHtml(ctx, value)}
    `; +} + +function steps(value: unknown): string { + if (typeof value !== "object" || value === null) return ""; + const record = value as Record; + return `
    ${escape(
    +    ["given", "when", "then"]
    +      .flatMap((phase) => {
    +        const rows = record[phase];
    +        return Array.isArray(rows)
    +          ? rows.map(
    +              (step: unknown) =>
    +                `${phase.charAt(0).toUpperCase()}${phase.slice(1)} ${typeof step === "string" ? step : JSON.stringify(step)}`,
    +            )
    +          : [];
    +      })
    +      .join("\n"),
    +  )}
    `; +} + +function sections(ctx: PageContext, spec: SpecContext): string { + return SPEC_SECTION_NAMES.flatMap((name) => { + const section = spec.sections?.[name]; + if (section === undefined) return []; + let body = ""; + let title = name === "ui" ? "UI" : name.charAt(0).toUpperCase() + name.slice(1); + if (name === "constraints") { + body = (spec.sections?.constraints ?? []) + .map( + (row) => + `
    ${Object.entries(row) + .map(([key, value]) => entry(ctx, spec, key, value)) + .join("")}
    `, + ) + .join(""); + } else { + const record = section as Record; + body = typeof record.description === "string" ? prose(ctx, record.description) : ""; + if (name === "behavior") { + title = + spec.specKind === "rule" + ? "Rule" + : spec.specKind === "workflow" + ? "Workflow" + : spec.specKind === "contract" + ? "Contract" + : "Behavior"; + const behavior = spec.sections?.behavior; + const rows = [ + ...(behavior?.rules ?? []).map((value, i) => ({ + value, + entry: `rule[${String(i)}]`, + path: `rules[${String(i)}]`, + label: "rule", + })), + ...(behavior?.flows ?? []).map((value, i) => ({ + value, + entry: `flow[${String(i)}]`, + path: `flows[${String(i)}]`, + label: "flow", + })), + ].sort( + (a, b) => (entryLine(spec, a.entry) ?? Infinity) - (entryLine(spec, b.entry) ?? Infinity), + ); + body += `
    ${rows + .map((row) => { + const key = Object.keys(behavior?.ruleKeys ?? {}).find( + (key) => behavior?.ruleKeys?.[key] === row.path, + ); + return entry( + ctx, + spec, + key === undefined ? row.label : `rule.${key}`, + row.value, + key === undefined ? undefined : `rule.${key}`, + row.entry, + ); + }) + .join("")}
    `; + if (behavior?.examples?.length) + body += `

    Examples

    ${behavior.examples.map((example) => (typeof example === "string" ? prose(ctx, example) : steps(example))).join("")}`; + if (behavior?.exampleSpace !== undefined) + body += `

    Example space

    ${steps(behavior.exampleSpace)}`; + } else if (name === "intent") { + body += `
    ${Object.entries(record) + .filter(([key]) => key !== "description" && key !== "openQuestions") + .map(([key, value]) => entry(ctx, spec, key, value)) + .join("")}
    `; + const questions = spec.designColumns.openQuestions; + if (questions.length) + body += `

    Open questions

      ${questions.map((q) => `${q.blocking ? "blocking" : "non-blocking"}${q.key === undefined ? "" : ` · ${code(`question.${q.key}`)}${copy(`${spec.id}#question.${q.key}`)}`}${lineLabel(q.line)} · ${text(ctx, q.question)}${questionFields(ctx, q, (v) => text(ctx, v))}`).join("")}
    `; + } else { + if (name === "verification" && typeof record.mode === "string") + title += ` · ${record.mode}`; + body += `
    ${Object.entries(record) + .filter(([key]) => key !== "description") + .map(([key, value]) => { + const address = + (name === "design" || name === "ui") && /^[a-z][A-Za-z0-9]*$/u.test(key) + ? `${name}.${key}` + : undefined; + return entry(ctx, spec, key, value, address); + }) + .join("")}
    `; + } + } + return [ + `
    ${escape(title)}
    ${body}
    `, + ]; + }).join(""); +} + +function relations(ctx: PageContext, spec: SpecContext): string { + const row = (r: RelationEnd, direction: string): string => + `${escape(r.type)} · ${direction}${link(ctx, r.otherId, r.otherTitle ?? r.otherId, codeSpans(r.otherTitle ?? r.otherId))}
    ${code(r.otherId)} · ${r.resolved ? "resolves" : "unresolved"}${escape(r.claim)}${r.reason === undefined ? "None" : text(ctx, r.reason)}${r.exercises?.length ? r.exercises.map(code).join(", ") : "None"}`; + return `

    Relations

    ${spec.relationsOut.length + spec.relationsIn.length === 0 ? "

    None.

    " : `
    ${spec.relationsOut.map((r) => row(r, "outgoing")).join("")}${spec.relationsIn.map((r) => row(r, "incoming")).join("")}
    Relation and directionOther endClaimReasonExercised keys
    `}`; +} + +function bindings(ctx: PageContext, spec: SpecContext): string { + const verifiers = list( + spec.verifiers.map( + (v) => + `${link(ctx, v.verifierId)}${v.label === undefined ? "" : ` · ${escape(v.label)}`} · ${escape(v.claim)} · ${code(location(v.file, v.line))}${v.file === undefined ? "" : copy(location(v.file, v.line))}${v.via === "example" ? ` · example ${v.enabled ? "enabled" : "not enabled"}` : " · test anchor"}`, + ), + "units", + ); + const oracle = spec.oracle; + return `

    Bindings

    Implementation binding: ${present(spec.implementations.length > 0)}

    ${spec.implementations.length ? unitList(spec.implementations) : ""}

    Design reference: ${present(spec.references.length > 0)}

    ${spec.references.length ? unitList(spec.references) : ""}

    Verifier binding: ${present(spec.deliveryFacts.includes("has-verifier"))}

    ${spec.verifiers.length ? verifiers : ""}

    Expected-outcome oracle: ${present(oracle !== undefined)}

    ${oracle === undefined ? "" : list([`${code(oracle.anchorId)}${oracle.label === undefined ? "" : ` · ${escape(oracle.label)}`} · ${code(location(oracle.file, oracle.line))}${oracle.file === undefined ? "" : copy(location(oracle.file, oracle.line))}`], "units")}

    Runtime observation: not tracked.

    Run results: not recorded in the graph.

    `; +} + +/** + * Writes the Spec context in section order, with address ids and both relation directions. + * @sdpAnchor impl:protocol.studio-spec-page + * @sdpSatisfies spec:consumers.spec-studio.spec-page.core + * @sdpReferences spec:consumers.spec-studio.spec-page + * @sdpComponent component:protocol.projections + * @sdpRole projection + */ +export function specPage(ctx: PageContext, spec: SpecContext): string { + const d = spec.designColumns; + const findings = ctx.data.findings.filter((f) => f.subjectId === spec.id); + const dishonest = + SPEC_READINESS.indexOf(spec.statedReadiness) > + (spec.derivedReadiness === undefined ? -1 : SPEC_READINESS.indexOf(spec.derivedReadiness)); + const firstUnmet = spec.floorFailures[0]; + const divergence = dishonest + ? `

    stated ${escape(spec.statedReadiness)} exceeds the floor ${escape(spec.derivedReadiness ?? "below idea")}.${firstUnmet === undefined ? "" : ` First unmet clause: ${code(firstUnmet.clauseId)} · ${escape(firstUnmet.description)}`}

    ` + : ""; + const header = `

    ${codeSpans(spec.title ?? spec.id)}

    ${code(spec.id)}${copy(spec.id)}${escape(spec.kindDisplayLabel ?? spec.specKind)} · ${escape(spec.altitude)}${rungLine(spec.statedReadiness, spec.derivedReadiness)}
    ${divergence}${holds(ctx, spec.statedNextRung, spec.statedNextRungFailures)}

    Packs: ${spec.packs.length ? spec.packs.map((id) => link(ctx, id)).join(", ") : "none"}

    Design: ${count(d.entries, "design entry", "design entries", false)} · ${count(d.declarations, "pinned declaration", "pinned declarations", false)} · ${count(d.decisions.length, "decision", "decisions", false)}

    Findings: ${count(findings.filter((f) => f.severity === "error").length, "error", "errors", false)}, ${count(findings.filter((f) => f.severity === "warning").length, "warning", "warnings", false)}

    ${code(spec.file)}${copy(spec.file)}

    `; + const summary = `

    ${count(d.openQuestions.length, "open question")} and ${count(d.blockingQuestions, "blocking question", "blocking questions", false)}; implementation binding ${present(spec.implementations.length > 0)}, verifier binding ${present(spec.deliveryFacts.includes("has-verifier"))}, and expected-outcome oracle ${present(spec.oracle !== undefined)}.

    `; + return frame( + ctx, + spec.title ?? spec.id, + `${header}${summary}${spec.narrative === undefined ? "" : `
    ${prose(ctx, spec.narrative)}
    `}${sections(ctx, spec)}${relations(ctx, spec)}${bindings(ctx, spec)}

    Findings

    ${findingList(ctx, findings)}
    `, + spec.id, + ); +} diff --git a/src/projections/studio/pages/text.ts b/src/projections/studio/pages/text.ts new file mode 100644 index 00000000..ab125b89 --- /dev/null +++ b/src/projections/studio/pages/text.ts @@ -0,0 +1,61 @@ +import { mentionTokens } from "../../../validate/validators.js"; +import { escape, link } from "./html.js"; +import type { PageContext } from "./html.js"; +import { mapCodeSpans } from "./code-spans.js"; +export { codeSpans } from "./code-spans.js"; + +/** + * Escapes text first and links only graph-resolving tokens read by the mention validator. + * @sdpAnchor impl:protocol.studio-text + * @sdpSatisfies spec:consumers.spec-studio.shell + * @sdpComponent component:protocol.projections + * @sdpRole codec + */ +export function text(ctx: PageContext, value: string): string { + const mentions = (part: string): string => { + // Keep source offsets while decoding the punctuation escapes the validator reads. + let decoded = ""; + const starts: number[] = []; + const ends: number[] = []; + for (let i = 0; i < part.length; i++) { + starts.push(i); + if (part[i] === "\\" && /[!-/:-@[-`{-~]/u.test(part[i + 1] ?? "")) i++; + decoded += part[i] ?? ""; + ends.push(i + 1); + } + let at = 0; + let decodedAt = 0; + let out = ""; + for (const token of mentionTokens(part)) { + const decodedStart = decoded.indexOf(token, decodedAt); + if (decodedStart < 0) continue; + const start = starts[decodedStart] ?? 0; + const end = ends[decodedStart + token.length - 1] ?? part.length; + out += escape(part.slice(at, start)) + link(ctx, token, part.slice(start, end)); + at = end; + decodedAt = decodedStart + token.length; + } + return out + escape(part.slice(at)); + }; + return mapCodeSpans(value, mentions, (v) => `${mentions(v)}`); +} + +export function prose(ctx: PageContext, value?: string): string { + return value === undefined + ? "" + : value + .split(/\n\s*\n/u) + .map((p) => `

    ${text(ctx, p)}

    `) + .join(""); +} + +export function valueHtml(ctx: PageContext, value: unknown): string { + if (typeof value === "string") return text(ctx, value); + if (Array.isArray(value)) + return `
      ${value.map((item) => `
    • ${valueHtml(ctx, item)}
    • `).join("")}
    `; + if (value !== null && typeof value === "object") + return `
    ${Object.entries(value) + .map(([key, item]) => `
    ${escape(key)}
    ${valueHtml(ctx, item)}
    `) + .join("")}
    `; + return escape(value); +} diff --git a/src/projections/studio/paths.ts b/src/projections/studio/paths.ts new file mode 100644 index 00000000..839c05e6 --- /dev/null +++ b/src/projections/studio/paths.ts @@ -0,0 +1,16 @@ +/** Pack and Spec ids admit no underscores, so the uppercase escape remains unambiguous. */ +/** + * @sdpAnchor impl:protocol.studio-paths + * @sdpLabel encodes uppercase id letters for collision-free Pack and Spec page paths + * @sdpSatisfies spec:consumers.spec-studio.publisher + * @sdpComponent component:protocol.projections + * @sdpRole utility + */ +export function pagePath(id: string): string | null { + const namespace = id.startsWith("spec:") ? "specs" : id.startsWith("pack:") ? "packs" : null; + if (namespace === null) return null; + const name = id + .slice(id.indexOf(":") + 1) + .replace(/[A-Z]/gu, (letter) => `_${letter.toLowerCase()}`); + return `${namespace}/${name}.html`; +} diff --git a/src/projections/studio/publish.ts b/src/projections/studio/publish.ts new file mode 100644 index 00000000..935b3d24 --- /dev/null +++ b/src/projections/studio/publish.ts @@ -0,0 +1,98 @@ +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, posix, relative, win32 } from "node:path"; + +import type { StudioPage } from "./pages/index.js"; + +export interface StudioAssets { + readonly script: string; + readonly stylesheet: string; +} + +export interface StudioFile { + readonly path: string; + readonly content: string | Uint8Array; +} + +/** + * @sdpAnchor impl:protocol.studio-publish + * @sdpLabel copies local assets and replaces the Studio directory through its temporary sibling + * @sdpSatisfies spec:consumers.spec-studio.publisher + * @sdpComponent component:protocol.projections + * @sdpRole publisher + */ +export function studioFiles( + pages: readonly StudioPage[], + assets: StudioAssets, +): readonly StudioFile[] { + for (const page of pages) { + if ( + page.path === "" || + page.path.includes("\\") || + posix.isAbsolute(page.path) || + win32.isAbsolute(page.path) || + posix.normalize(page.path) !== page.path || + page.path.split("/").some((part) => part === ".." || part === ".") + ) { + throw new Error(`unsafe Studio page path: ${page.path}`); + } + } + return [ + ...pages, + { path: "assets/studio.js", content: readFileSync(assets.script) }, + { path: "assets/studio.css", content: readFileSync(assets.stylesheet) }, + ].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); +} + +export function readPublishedStudio(root: string): readonly StudioFile[] | undefined { + if (!existsSync(root)) return undefined; + const files: StudioFile[] = []; + const walk = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) walk(path); + else if (entry.isFile()) + files.push({ + path: relative(root, path).replaceAll("\\", "/"), + content: readFileSync(path), + }); + else throw new Error(`generated Studio contains a non-file entry: ${entry.name}`); + } + }; + walk(root); + return files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); +} + +export function studioFilesEqual( + left: readonly StudioFile[], + right: readonly StudioFile[], +): boolean { + return ( + left.length === right.length && + left.every((file, index) => { + const other = right[index]; + return ( + file.path === other?.path && Buffer.from(file.content).equals(Buffer.from(other.content)) + ); + }) + ); +} + +export function publishStudio(root: string, files: readonly StudioFile[]): void { + const temporary = `${root}.tmp`; + rmSync(temporary, { recursive: true, force: true }); + for (const file of files) { + const target = join(temporary, file.path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, file.content); + } + rmSync(root, { recursive: true, force: true }); + renameSync(temporary, root); +} diff --git a/src/projections/studio/studio.css b/src/projections/studio/studio.css new file mode 100644 index 00000000..324c33a4 --- /dev/null +++ b/src/projections/studio/studio.css @@ -0,0 +1,630 @@ +/* One hue in four lightness steps orders the rungs; each badge always carries its word. */ + +:root { + --bg: #fbfbfa; + --surface: #ffffff; + --surface-2: #f3f4f2; + --text: #1d2125; + --muted: #5b636b; + --line: #d9dcd8; + --link: #1f5fa8; + --focus: #c25e00; + --warn-bg: #fff4d6; + --warn-text: #6b4500; + --warn-line: #e0b54a; + --code-bg: #eef0ec; + /* The rung palette: one hue in four lightness steps, so it stays ordered under any color + vision; the word is always inside the badge. */ + --rung-idea-bg: #eef3f9; + --rung-idea-text: #26425f; + --rung-scoped-bg: #c8daf0; + --rung-scoped-text: #1c3854; + --rung-defined-bg: #356eae; + --rung-defined-text: #ffffff; + --rung-ready-bg: #1f4878; + --rung-ready-text: #ffffff; + --measure: 80ch; + --radius: 6px; + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #15181b; + --surface: #1c2024; + --surface-2: #23282d; + --text: #e6e8ea; + --muted: #a3abb3; + --line: #353b41; + --link: #8cbcf2; + --focus: #ffb062; + --warn-bg: #3a2e12; + --warn-text: #f3d68f; + --warn-line: #8a6a1e; + --code-bg: #262c31; + --rung-idea-bg: #26313d; + --rung-idea-text: #d6e3f2; + --rung-scoped-bg: #2f4b6b; + --rung-scoped-text: #e4eefa; + --rung-defined-bg: #356eae; + --rung-defined-text: #ffffff; + --rung-ready-bg: #9cc3f0; + --rung-ready-text: #0d2238; + color-scheme: dark; + } +} + +* { + box-sizing: border-box; +} + +html { + font-size: 100%; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: + 15px/1.55 system-ui, + -apple-system, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + sans-serif; +} + +a { + color: var(--link); +} + +a:focus-visible, +button:focus-visible, +select:focus-visible, +summary:focus-visible, +input:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.88em; + background: var(--code-bg); + border-radius: 3px; + padding: 0.05em 0.3em; + overflow-wrap: anywhere; +} + +.skip-link { + position: absolute; + left: -999px; + top: 0; + background: var(--surface); + padding: 0.5rem 1rem; + z-index: 10; +} + +.skip-link:focus { + left: 1rem; +} + +/* The shell. */ +.top { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.25rem; + align-items: center; + padding: 0.6rem 1rem; + border-bottom: 1px solid var(--line); + background: var(--surface); +} + +.top .brand { + font-weight: 650; + text-decoration: none; + color: var(--text); +} + +.top nav a { + margin-right: 0.9rem; +} + +.top form { + display: flex; + gap: 0.4rem; + margin-left: auto; +} + +.top input[type="search"] { + font: inherit; + padding: 0.3rem 0.5rem; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + width: 16rem; + max-width: 50vw; +} + +button { + font: inherit; + font-size: 0.9rem; + padding: 0.3rem 0.7rem; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-2); + color: var(--text); + cursor: pointer; +} + +.layout { + display: grid; + grid-template-columns: minmax(14rem, 18rem) minmax(0, 1fr); + gap: 0 2rem; + max-width: 82rem; + margin: 0 auto; + padding: 1rem; +} + +.rail { + font-size: 0.9rem; + position: sticky; + top: 0.5rem; + align-self: start; + max-height: calc(100vh - 1rem); + overflow: auto; + padding-right: 0.5rem; +} + +.rail h2 { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin: 1rem 0 0.4rem; +} + +.rail ol, +.rail ul { + margin: 0; + padding-left: 1.4rem; +} + +.rail li { + margin: 0.2rem 0; +} + +.rail a[aria-current="page"] { + font-weight: 650; + color: var(--text); + text-decoration: none; +} + +.rail a[aria-current="page"]::before { + content: "▸ "; +} + +main { + min-width: 0; +} + +h1 { + font-size: 1.7rem; + line-height: 1.25; + margin: 0.15rem 0 0.5rem; +} + +h2 { + font-size: 1.25rem; + margin: 2rem 0 0.6rem; +} + +h3 { + font-size: 1rem; + margin: 1rem 0 0.35rem; +} + +.prose, +.prose p, +.summary { + max-width: var(--measure); +} + +.ids { + display: flex; + flex-wrap: wrap; + gap: 0.3rem 1rem; + align-items: center; + margin: 0.2rem 0 0.8rem; + color: var(--muted); +} + +.ids > span { + display: inline-flex; + align-items: center; + gap: 0.25rem; + max-width: 100%; +} + +.ids > span > code { + min-width: 0; +} + +sdp-copy { + flex-shrink: 0; + white-space: nowrap; +} + +.summary { + margin: 0; + padding-left: 1.2rem; +} + +.summary li { + margin: 0.15rem 0; +} + +/* Rung badges and the other words that carry a status. */ +.badge { + display: inline-block; + font-size: 0.78rem; + line-height: 1.4; + padding: 0.05rem 0.45rem; + border-radius: 999px; + border: 1px solid var(--line); + white-space: nowrap; +} + +.rung-idea { + background: var(--rung-idea-bg); + color: var(--rung-idea-text); + border-color: transparent; +} + +.rung-scoped { + background: var(--rung-scoped-bg); + color: var(--rung-scoped-text); + border-color: transparent; +} + +.rung-defined { + background: var(--rung-defined-bg); + color: var(--rung-defined-text); + border-color: transparent; +} + +.rung-ready { + background: var(--rung-ready-bg); + color: var(--rung-ready-text); + border-color: transparent; +} + +.floor { + color: var(--muted); + font-size: 0.85rem; + white-space: nowrap; +} + +/* Member cards. */ +.card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + margin: 0.45rem 0; +} + +.card:target, +.card:has(:target) { + border-color: var(--focus); +} + +.card > summary { + cursor: pointer; + padding: 0.55rem 0.8rem; + list-style-position: outside; + margin-left: 1rem; +} + +.card > summary .line1 { + font-weight: 600; +} + +.card > summary .line2 { + display: block; + margin-top: 0.25rem; + font-weight: 400; +} + +.card-body { + padding: 0.2rem 1rem 0.9rem 1.8rem; + border-top: 1px solid var(--line); +} + +.card-body h3 { + font-size: 0.95rem; + color: var(--muted); + margin-top: 0.9rem; +} + +.card-body p, +.card-body li { + max-width: var(--measure); +} + +.questions { + padding-left: 1.2rem; + margin: 0.3rem 0; +} + +.questions > li { + margin: 0.5rem 0; +} + +.field { + display: block; + margin-top: 0.15rem; + font-size: 0.9rem; +} + +.field b { + font-weight: 600; + color: var(--muted); +} + +.units { + list-style: none; + padding: 0; + margin: 0.2rem 0; +} + +.units li { + margin: 0.35rem 0; +} + +/* Sections that close: the boundary and the lists after the cards. */ +.section { + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + margin: 0.45rem 0; +} + +.section > summary { + cursor: pointer; + padding: 0.5rem 0.8rem; + font-weight: 600; +} + +.section > .section-body { + padding: 0 0.8rem 0.8rem; +} + +.table-scroll { + overflow-x: auto; +} + +table { + border-collapse: collapse; + width: 100%; + font-size: 0.9rem; +} + +th, +td { + text-align: left; + vertical-align: top; + padding: 0.35rem 0.5rem; + border-bottom: 1px solid var(--line); +} + +th { + color: var(--muted); + font-weight: 600; +} + +.plain-list { + padding-left: 1.2rem; +} + +sdp-copy button { + font-size: 0.75rem; + padding: 0.05rem 0.45rem; + margin-left: 0.25rem; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +/* Below the phone breakpoint every layout is one column and the rail follows the canvas. */ +@media (max-width: 720px) { + .layout { + grid-template-columns: minmax(0, 1fr); + } + + .rail { + position: static; + max-height: none; + order: 2; + border-top: 1px solid var(--line); + margin-top: 2rem; + } + + main { + order: 1; + } + + .top form { + margin-left: 0; + width: 100%; + } + + .top input[type="search"] { + flex: 1; + max-width: none; + } + + .card-body { + padding-left: 1rem; + } +} + +/* Static sections, rows, entry addresses and the search element. */ +[hidden] { + display: none !important; +} +sdp-search { + margin-left: auto; + min-width: 0; +} +sdp-expander, +sdp-filter, +sdp-search-results { + display: block; +} +sdp-expander > button { + margin: 0.4rem 0.4rem 0.6rem 0; +} +sdp-search-results form { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: end; +} +sdp-search-results input, +select { + font: inherit; + background: var(--surface); + color: var(--text); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0.4rem; + max-width: 100%; +} +sdp-search-results label { + display: grid; + gap: 0.3rem; + flex: 1; +} +.filter-bar { + display: flex; + flex-wrap: wrap; + gap: 0.7rem; + margin-bottom: 0.7rem; +} +.filter-bar label { + display: grid; + gap: 0.2rem; + font-size: 0.85rem; +} +.spec-list { + padding: 0; + list-style: none; +} +.spec-row { + padding: 0.75rem 0; + border-bottom: 1px solid var(--line); +} +.entry { + margin: 0.75rem 0; +} +dt { + font-weight: 600; +} +dd { + margin: 0.3rem 0 0.5rem 1rem; + max-width: var(--measure); +} +.entry-line { + color: var(--muted); + font-weight: 400; + font-size: 0.85rem; +} +pre { + white-space: pre-wrap; + overflow-wrap: anywhere; + max-width: var(--measure); + padding: 0.75rem; + background: var(--code-bg); + border-radius: var(--radius); + font-size: 0.9rem; +} +p, +li, +dd, +td, +.line1 { + overflow-wrap: anywhere; +} +.divergence { + background: var(--warn-bg); + color: var(--warn-text); + border: 1px solid var(--warn-line); + border-radius: var(--radius); + padding: 0.65rem; + max-width: var(--measure); +} +.rungs { + white-space: nowrap; +} +.search-result { + padding: 0.8rem 0 1rem; + border-bottom: 1px solid var(--line); +} +.search-result h2 { + margin: 0.2rem 0 0.5rem; +} +.search-result p { + margin: 0.4rem 0; + max-width: var(--measure); +} +a { + text-underline-offset: 0.15em; +} +a:hover { + text-decoration-thickness: 0.12em; +} +button:hover { + border-color: var(--muted); +} +input, +select { + caret-color: var(--link); +} +::selection { + background: var(--rung-scoped-bg); + color: var(--rung-scoped-text); +} +output { + display: block; + color: var(--muted); + font-variant-numeric: tabular-nums; +} +sdp-copy output { + display: inline; + margin-left: 0.25rem; +} +@media (max-width: 720px) { + sdp-search { + width: 100%; + margin-left: 0; + } + .top input[type="search"] { + min-width: 0; + width: 100%; + } + .rungs { + white-space: normal; + } + .layout { + padding: 1rem 0.75rem; + } +} diff --git a/src/reader/concept-match.ts b/src/reader/concept-match.ts new file mode 100644 index 00000000..e6e60db2 --- /dev/null +++ b/src/reader/concept-match.ts @@ -0,0 +1,97 @@ +export interface ConceptRecord { + readonly id: string; + readonly nodeType: "Primitive" | "Pack" | "Anchor" | "CodeNode"; + readonly title?: string; + readonly label?: string; + readonly framing?: string; + readonly narrative?: string; + readonly sections?: object; +} + +export type ConceptMatchField = + | "id" + | "title" + | "label" + | "framing" + | "narrative" + | `sections.${string}`; + +export interface ConceptMatch { + readonly id: string; + readonly nodeType: ConceptRecord["nodeType"]; + readonly title?: string; + readonly matchedIn: readonly ConceptMatchField[]; +} + +function compare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function contains(value: unknown, needle: string, includeKeys: boolean): boolean { + if (typeof value === "string") return value.toLowerCase().includes(needle); + if (Array.isArray(value)) + return value.some((entry: unknown) => contains(entry, needle, includeKeys)); + if (typeof value === "object" && value !== null) { + return Object.entries(value).some( + ([key, entry]: [string, unknown]) => + (includeKeys && key.toLowerCase().includes(needle)) || contains(entry, needle, includeKeys), + ); + } + return false; +} + +/** + * Ranks matches by the first field that matched, then by id using code-unit order. + * + * @sdpAnchor impl:protocol.studio-concept-match + * @sdpSatisfies spec:consumers.spec-studio.search + * @sdpComponent component:protocol.reader + * @sdpRole utility + */ +export function matchConcept( + records: readonly ConceptRecord[], + term: string, +): readonly ConceptMatch[] { + const needle = term.toLowerCase().trim(); + if (needle.length === 0) return []; + const fieldRank: Readonly> = { + id: 0, + title: 1, + label: 2, + framing: 3, + narrative: 4, + }; + const matches: { match: ConceptMatch; rank: number }[] = []; + for (const node of [...records].sort((a, b) => compare(a.id, b.id))) { + const matchedIn: ConceptMatchField[] = []; + if (node.id.toLowerCase().includes(needle)) matchedIn.push("id"); + if (node.nodeType === "Primitive" || node.nodeType === "Pack") { + if (node.title?.toLowerCase().includes(needle)) matchedIn.push("title"); + } else if (node.label?.toLowerCase().includes(needle)) matchedIn.push("label"); + if (node.nodeType === "Pack" && node.framing?.toLowerCase().includes(needle)) + matchedIn.push("framing"); + if (node.nodeType === "Primitive") { + if (node.narrative?.toLowerCase().includes(needle)) matchedIn.push("narrative"); + for (const [name, content] of Object.entries(node.sections ?? {}).sort(([a], [b]) => + compare(a, b), + )) { + if (contains(content, needle, name === "model")) matchedIn.push(`sections.${name}`); + } + } + if (matchedIn.length === 0) continue; + matches.push({ + match: { + id: node.id, + nodeType: node.nodeType, + ...((node.nodeType === "Primitive" || node.nodeType === "Pack") && node.title !== undefined + ? { title: node.title } + : {}), + matchedIn, + }, + rank: Math.min(...matchedIn.map((field) => fieldRank[field] ?? 4)), + }); + } + return matches + .sort((a, b) => a.rank - b.rank || compare(a.match.id, b.match.id)) + .map(({ match }) => match); +} diff --git a/src/reader/reader.ts b/src/reader/reader.ts index 9b4046e4..3d49d701 100644 --- a/src/reader/reader.ts +++ b/src/reader/reader.ts @@ -1,3 +1,8 @@ +import { matchConcept } from "./concept-match.js"; +import type { ConceptMatch } from "./concept-match.js"; +export { matchConcept } from "./concept-match.js"; +export type { ConceptRecord, ConceptMatch, ConceptMatchField } from "./concept-match.js"; + import { computeDeliveryFacts, isEnabledExampleVerify, @@ -294,22 +299,6 @@ export interface PackContext extends PackSummary { readonly findings: readonly Finding[]; } -export type ConceptMatchField = - | "id" - | "title" - | "label" - | "framing" - | "narrative" - | `sections.${string}`; - -/** Where a concept search hit: the node plus the fields that matched. */ -export interface ConceptMatch { - readonly id: string; - readonly nodeType: GraphNodeType; - readonly title?: string; - readonly matchedIn: readonly ConceptMatchField[]; -} - export interface FileNodeRef { readonly id: string; readonly nodeType: GraphNodeType; @@ -464,45 +453,6 @@ function compareRelationEnds(left: RelationEnd, right: RelationEnd): number { return compareCodeUnits(left.type, right.type) || compareCodeUnits(left.otherId, right.otherId); } -/** Deep scan of reified section content for a substring; returns matched section names. */ -function matchSections(sections: SpecSections | undefined, needle: string): readonly string[] { - if (sections === undefined) { - return []; - } - - const containsNeedle = (value: unknown, includeKeys: boolean): boolean => { - if (typeof value === "string") { - return value.toLowerCase().includes(needle); - } - - if (Array.isArray(value)) { - return value.some((entry) => containsNeedle(entry, includeKeys)); - } - - if (typeof value === "object" && value !== null) { - return Object.entries(value as Record).some( - ([key, entry]) => - (includeKeys && key.toLowerCase().includes(needle)) || containsNeedle(entry, includeKeys), - ); - } - - return false; - }; - - const matched: string[] = []; - - for (const [name, content] of Object.entries(sections as Record)) { - // String values are content everywhere; record keys are content only in `model.terms`, where - // the keys *are* the vocabulary (`spec:model.spec-sections`) — structural keys (`given`, - // `outcome`) never match. - if (containsNeedle(content, name === "model")) { - matched.push(name); - } - } - - return matched.sort(compareCodeUnits); -} - /* ----- the Pack design (`spec:consumers.pack-design`) ----- */ /** A value that opens with a code span, by recipe 24's rule: a declaration as authored. */ @@ -1064,76 +1014,8 @@ export function createReader(graph: GraphSchema): Reader { }; }; - const findByConcept = (text: string): readonly ConceptMatch[] => { - const needle = text.toLowerCase().trim(); - - if (needle.length === 0) { - return []; - } - - const fieldRank: Readonly> = { - id: 0, - title: 1, - label: 2, - framing: 3, - narrative: 4, - }; - const rankOf = (field: ConceptMatchField): number => fieldRank[field] ?? 4; - const matches: { match: ConceptMatch; bestRank: number }[] = []; - - for (const node of [...index.nodesById.values()].sort((left, right) => - compareCodeUnits(left.id, right.id), - )) { - const matchedIn: ConceptMatchField[] = []; - - if (node.id.toLowerCase().includes(needle)) { - matchedIn.push("id"); - } - - if (node.nodeType === "Primitive" || node.nodeType === "Pack") { - if (node.title?.toLowerCase().includes(needle) === true) { - matchedIn.push("title"); - } - } else if (node.label?.toLowerCase().includes(needle) === true) { - matchedIn.push("label"); - } - - if (node.nodeType === "Pack" && node.framing?.toLowerCase().includes(needle) === true) { - matchedIn.push("framing"); - } - - if (node.nodeType === "Primitive") { - if (node.narrative?.toLowerCase().includes(needle) === true) { - matchedIn.push("narrative"); - } - - for (const section of matchSections(node.sections, needle)) { - matchedIn.push(`sections.${section}`); - } - } - - if (matchedIn.length === 0) { - continue; - } - - matches.push({ - match: { - id: node.id, - nodeType: node.nodeType, - ...(titleOf(index, node.id) === undefined ? {} : { title: titleOf(index, node.id) }), - matchedIn, - }, - bestRank: Math.min(...matchedIn.map(rankOf)), - }); - } - - return matches - .sort( - (left, right) => - left.bestRank - right.bestRank || compareCodeUnits(left.match.id, right.match.id), - ) - .map((entry) => entry.match); - }; + const findByConcept = (text: string): readonly ConceptMatch[] => + matchConcept([...index.nodesById.values()], text); const byFile = (path: string): FileEntry => { const normalized = normalizePath(path); diff --git a/src/studio-elements/copy.ts b/src/studio-elements/copy.ts new file mode 100644 index 00000000..3054bc0f --- /dev/null +++ b/src/studio-elements/copy.ts @@ -0,0 +1,73 @@ +import { button, liveOutput } from "./dom.js"; + +export type ClipboardWriter = (text: string) => Promise; + +export async function copyText( + text: string, + write: ClipboardWriter | undefined, + select: (text: string) => void, +): Promise { + if (write) { + try { + await write(text); + return "Copied"; + } catch { + /* Offer selection when clipboard access fails. */ + } + } + select(text); + return "Text selected. Copy it with your keyboard."; +} + +/** + * Falls back to a focused, selected textarea when clipboard access is unavailable. + * + * @sdpAnchor impl:protocol.studio-copy + * @sdpSatisfies spec:consumers.spec-studio.page-elements + * @sdpComponent component:protocol.studio-elements + * @sdpRole utility + */ +export function registerCopy(): void { + class Copy extends globalThis.HTMLElement { + private control?: HTMLButtonElement; + private selected?: HTMLTextAreaElement; + static get observedAttributes(): string[] { + return ["text"]; + } + + attributeChangedCallback(): void { + this.control?.setAttribute("aria-label", `Copy ${this.getAttribute("text") ?? ""}`); + } + + connectedCallback(): void { + if (this.control) return; + this.control = button(this, "Copy"); + this.attributeChangedCallback(); + this.control.addEventListener("click", () => { + const clipboard = this.ownerDocument.defaultView?.navigator.clipboard; + const write = + clipboard === undefined + ? undefined + : (text: string): Promise => clipboard.writeText(text); + void copyText(this.getAttribute("text") ?? "", write, (text) => { + this.selected?.parentElement?.remove(); + const label = this.ownerDocument.createElement("label"); + label.textContent = "Text to copy "; + this.selected = this.ownerDocument.createElement("textarea"); + this.selected.readOnly = true; + this.selected.value = text; + label.append(this.selected); + this.append(label); + this.selected.focus(); + this.selected.select(); + }).then((message) => { + liveOutput(this).textContent = message; + }); + }); + this.append(this.control); + liveOutput(this); + } + } + if (!globalThis.customElements.get("sdp-copy")) + globalThis.customElements.define("sdp-copy", Copy); +} diff --git a/src/studio-elements/dom.ts b/src/studio-elements/dom.ts new file mode 100644 index 00000000..c189d6d4 --- /dev/null +++ b/src/studio-elements/dom.ts @@ -0,0 +1,21 @@ +/// + +export function liveOutput(host: HTMLElement): HTMLOutputElement { + const output = + Array.from(host.querySelectorAll("output")).find( + (candidate) => + candidate.closest("sdp-filter, sdp-expander, sdp-copy, sdp-search-results") === host, + ) ?? host.ownerDocument.createElement("output"); + output.setAttribute("aria-live", "polite"); + if (!output.parentNode) host.append(output); + return output; +} + +export function button(host: HTMLElement, text: string): HTMLButtonElement { + const control = host.ownerDocument.createElement("button"); + control.type = "button"; + control.textContent = text; + return control; +} + +export { escapeHtml } from "../projections/studio/pages/code-spans.js"; diff --git a/src/studio-elements/expander.ts b/src/studio-elements/expander.ts new file mode 100644 index 00000000..3f2ae802 --- /dev/null +++ b/src/studio-elements/expander.ts @@ -0,0 +1,67 @@ +import { button, liveOutput } from "./dom.js"; + +export function fragmentTarget(fragment: string): string | null { + if (fragment.length < 2) return null; + try { + return decodeURIComponent(fragment.slice(1)); + } catch { + return null; + } +} + +/** + * Opens every enclosing details before scrolling to a fragment target. + * + * @sdpAnchor impl:protocol.studio-expander + * @sdpSatisfies spec:consumers.spec-studio.page-elements + * @sdpComponent component:protocol.studio-elements + * @sdpRole utility + */ +export function registerExpander(): void { + class Expander extends globalThis.HTMLElement { + private initialized = false; + private readonly reveal = (): void => { + const id = fragmentTarget(this.ownerDocument.location.hash); + const target = id === null ? null : this.ownerDocument.getElementById(id); + if (!target || !this.contains(target)) return; + for (const details of Array.from(this.querySelectorAll("details"))) { + if (details.contains(target)) details.open = true; + } + target.scrollIntoView(); + liveOutput(this).textContent = `Opened ${id ?? "entry"}`; + }; + + private setOpen(open: boolean): void { + const details = Array.from(this.querySelectorAll("details")); + for (const panel of details) panel.open = open; + liveOutput(this).textContent = + `${open ? "Opened" : "Closed"} ${String(details.length)} panels`; + } + + connectedCallback(): void { + if (!this.initialized) { + const controls = this.ownerDocument.createElement("div"); + const open = button(this, "Open all"); + const close = button(this, "Close all"); + open.addEventListener("click", () => { + this.setOpen(true); + }); + close.addEventListener("click", () => { + this.setOpen(false); + }); + controls.append(open, close); + this.prepend(controls); + liveOutput(this); + this.initialized = true; + } + this.ownerDocument.defaultView?.addEventListener("hashchange", this.reveal); + this.reveal(); + } + + disconnectedCallback(): void { + this.ownerDocument.defaultView?.removeEventListener("hashchange", this.reveal); + } + } + if (!globalThis.customElements.get("sdp-expander")) + globalThis.customElements.define("sdp-expander", Expander); +} diff --git a/src/studio-elements/filter.ts b/src/studio-elements/filter.ts new file mode 100644 index 00000000..83136a1e --- /dev/null +++ b/src/studio-elements/filter.ts @@ -0,0 +1,62 @@ +import { liveOutput } from "./dom.js"; + +export function rowMatches( + row: Readonly>, + facets: Readonly>, +): boolean { + return Object.entries(facets).every( + ([name, chosen]) => chosen === "any" || (row[name] ?? "").split(/\s+/u).includes(chosen), + ); +} + +/** + * Reads each row's facet attributes and changes only its hidden state. + * + * @sdpAnchor impl:protocol.studio-filter + * @sdpSatisfies spec:consumers.spec-studio.page-elements + * @sdpReferences spec:consumers.spec-studio.components + * @sdpComponent component:protocol.studio-elements + * @sdpRole utility + */ +export function registerFilter(): void { + class Filter extends globalThis.HTMLElement { + private readonly update = (): void => { + const facets = Object.fromEntries( + Array.from(this.querySelectorAll("select[data-facet]"), (select) => [ + select.getAttribute("data-facet") ?? "", + select.value, + ]), + ); + const rows = Array.from(this.querySelectorAll("[data-row]")); + for (const row of rows) { + const values = Object.fromEntries( + Object.keys(facets).map((name) => [name, row.getAttribute(`data-${name}`) ?? ""]), + ); + row.hidden = !rowMatches(values, facets); + } + liveOutput(this).textContent = + `${String(rows.filter((row) => !row.hidden).length)} of ${String(rows.length)} shown`; + }; + + connectedCallback(): void { + const form = this.querySelector("form"); + if (form) { + form.hidden = false; + form.addEventListener("submit", this.preventSubmit); + } + this.addEventListener("change", this.update); + this.update(); + } + + private readonly preventSubmit = (event: Event): void => { + event.preventDefault(); + }; + + disconnectedCallback(): void { + this.removeEventListener("change", this.update); + this.querySelector("form")?.removeEventListener("submit", this.preventSubmit); + } + } + if (!globalThis.customElements.get("sdp-filter")) + globalThis.customElements.define("sdp-filter", Filter); +} diff --git a/src/studio-elements/index.ts b/src/studio-elements/index.ts new file mode 100644 index 00000000..19053965 --- /dev/null +++ b/src/studio-elements/index.ts @@ -0,0 +1,22 @@ +import { registerCopy } from "./copy.js"; +import { registerExpander } from "./expander.js"; +import { registerFilter } from "./filter.js"; +import { registerSearch } from "./search.js"; +import { registerSearchResults } from "./search-results.js"; +import { registerStamp } from "./stamp.js"; + +/** + * Registers the browser interactions from one bundled entry point. + * + * @sdpAnchor component:protocol.studio-elements + * @sdpSatisfies spec:consumers.spec-studio.page-elements + * @sdpUses component:protocol.reader, component:protocol.projections + * @sdpLayer edge + * @sdpContext protocol + */ +registerSearch(); +registerSearchResults(); +registerFilter(); +registerExpander(); +registerCopy(); +registerStamp(); diff --git a/src/studio-elements/search-results.ts b/src/studio-elements/search-results.ts new file mode 100644 index 00000000..982ee4d3 --- /dev/null +++ b/src/studio-elements/search-results.ts @@ -0,0 +1,333 @@ +import type { ConceptRecord } from "../reader/concept-match.js"; +import { matchConcept } from "../reader/concept-match.js"; +import { button, escapeHtml, liveOutput } from "./dom.js"; +import { searchFragment, searchTerm } from "./search.js"; +import { pagePath } from "../projections/studio/paths.js"; +import { codeSpans } from "../projections/studio/pages/code-spans.js"; + +export interface SearchEntry { + readonly section: string; + readonly entry: string | null; + readonly key: string | null; + readonly address: string | null; + readonly line: number | null; + readonly text: string; +} + +export interface SearchRecord extends ConceptRecord { + readonly page: string | null; + readonly file?: string; + readonly line?: number; + readonly specKind?: string; + readonly statedReadiness?: string; + readonly floorReached?: string | null; + readonly bindings: readonly { readonly edge: string; readonly specId: string }[]; + readonly entries: readonly SearchEntry[]; +} + +export interface SearchIndex { + readonly records: readonly SearchRecord[]; +} +export interface SearchHit { + readonly record: SearchRecord; + readonly matchedIn: readonly string[]; + readonly entries: readonly SearchEntry[]; +} + +const tokensOf = (text: string): readonly string[] => + text + .replace(/(\p{Lu}+)(\p{Lu}\p{Ll})/gu, "$1 $2") + .replace(/([\p{Ll}\p{N}])(\p{Lu})/gu, "$1 $2") + .toLowerCase() + .split(/[^\p{L}\p{N}]+/u) + .filter((token) => token.length > 0); + +function hasTokens(text: string, wanted: readonly string[]): boolean { + const tokens = tokensOf(text); + return ( + wanted.length > 0 && + tokens.some((_token, start) => + wanted.every((token, offset) => tokens[start + offset] === token), + ) + ); +} + +// Serialized non-string values are not authored prose. Walk the original sections once per +// search and identify strings by section, entry path and text, without changing the index shape. +function stringEntries(record: SearchRecord): ReadonlySet { + const strings = new Set(); + for (const [section, content] of Object.entries(record.sections ?? {})) { + const walk = (value: unknown, path: string | null): void => { + if (typeof value === "string") strings.add(JSON.stringify([section, path, value])); + else if (Array.isArray(value)) + value.forEach((item: unknown, i) => { + walk(item, `${path ?? ""}[${String(i)}]`); + }); + else if (typeof value === "object" && value !== null) + for (const [key, item] of Object.entries(value)) + walk(item, path === null ? key : `${path}.${key}`); + }; + walk(content, null); + } + return strings; +} + +function matchesEntry( + entry: SearchEntry, + wanted: readonly string[], + strings: ReadonlySet, +): boolean { + return ( + (entry.key !== null && hasTokens(entry.key, wanted)) || + ((entry.section === "narrative" || + strings.has(JSON.stringify([entry.section, entry.entry, entry.text]))) && + hasTokens(entry.text, wanted)) + ); +} + +export function entryMatches(record: SearchRecord, entry: SearchEntry, term: string): boolean { + return matchesEntry(entry, tokensOf(term), stringEntries(record)); +} + +/** + * Keeps concept hits in their existing order and appends key/file-only hits by id. + * + * @sdpAnchor impl:protocol.studio-search-results + * @sdpSatisfies spec:consumers.spec-studio.search, spec:consumers.spec-studio.page-elements + * @sdpReferences spec:consumers.spec-studio.components + * @sdpComponent component:protocol.studio-elements + * @sdpRole projection + */ +export function searchRecords(index: SearchIndex, term: string): readonly SearchHit[] { + const needle = term.toLowerCase().trim(); + if (needle.length === 0) return []; + const wanted = tokensOf(term); + const concepts = matchConcept(index.records, term); + const byId = new Map(index.records.map((record) => [record.id, record])); + const conceptIds = new Set(concepts.map((hit) => hit.id)); + const makeHit = (record: SearchRecord, fields: readonly string[]): SearchHit => { + const strings = stringEntries(record); + const entries = + record.nodeType === "Primitive" + ? record.entries.filter((entry) => matchesEntry(entry, wanted, strings)) + : []; + const matchedIn = [...fields]; + if ( + record.entries.some( + (entry) => + entry.key !== null && + (entry.key.toLowerCase().includes(needle) || hasTokens(entry.key, wanted)), + ) + ) + matchedIn.push("key"); + if (record.file?.toLowerCase().includes(needle)) matchedIn.push("file"); + if (matchedIn.length === 0) + for (const entry of entries) { + const field = entry.section === "narrative" ? "narrative" : `sections.${entry.section}`; + if (!matchedIn.includes(field)) matchedIn.push(field); + } + return { + record, + matchedIn, + entries, + }; + }; + const base = concepts.flatMap((hit) => { + const record = byId.get(hit.id); + return record === undefined ? [] : [makeHit(record, hit.matchedIn)]; + }); + const added = index.records + .filter((record) => !conceptIds.has(record.id)) + .map((record) => makeHit(record, [])) + .filter((hit) => hit.matchedIn.length > 0) + .sort((a, b) => (a.record.id < b.record.id ? -1 : a.record.id > b.record.id ? 1 : 0)); + return [...base, ...added]; +} + +export function entryUrl(record: SearchRecord, entry: SearchEntry): string { + const fragment = + entry.address?.split("#")[1] ?? + (entry.section === "narrative" ? "narrative" : `section.${entry.section}`); + return `${record.page ?? ""}#${fragment}`; +} + +export function resultPage( + hits: readonly SearchHit[], + limit: number, +): { shown: readonly SearchHit[]; total: number; entryTotal: number; remaining: number } { + const shown = hits.slice(0, limit); + return { + shown, + total: hits.length, + entryTotal: hits.reduce((count, hit) => count + hit.entries.length, 0), + remaining: hits.length - shown.length, + }; +} + +const fieldsSearched = + "Fields searched: ids, titles, labels, Pack framing, Spec narrative and sections, coined keys, recorded file paths."; +const nodeWords = { Primitive: "Spec", Pack: "Pack", Anchor: "Anchor", CodeNode: "Code unit" }; +const link = (url: string, label: string, htmlLabel = escapeHtml(label)): string => + `${htmlLabel}`; +const plural = (n: number, singular: string, plural = `${singular}s`): string => + `${String(n)} ${n === 1 ? singular : plural}`; +const sectionTitle = (name: string): string => + name === "ui" ? "UI" : name.charAt(0).toUpperCase() + name.slice(1); +function fieldLabel(field: string): string { + if (field.startsWith("sections.")) return `the ${sectionTitle(field.slice(9))} section`; + return ( + ( + { + key: "a coined key", + file: "the file path", + framing: "Pack framing", + narrative: "narrative", + id: "id", + title: "title", + label: "label", + } as Record + )[field] ?? field + ); +} + +function excerpt(text: string, term: string): string { + if (text.length <= 180) return text; + const at = text.toLowerCase().indexOf(term.toLowerCase().trim()); + const start = at < 60 ? 0 : text.lastIndexOf(" ", at - 60) + 1; + const boundary = text.indexOf(" ", start + 180); + const end = boundary < 0 ? text.length : boundary; + return `${start > 0 ? "…" : ""}${text.slice(start, end).trimEnd()}${end < text.length ? "…" : ""}`; +} + +export function renderResults(hits: readonly SearchHit[], term: string, limit: number): string { + const page = resultPage(hits, limit); + if (term.trim().length === 0) return `

    Type a word to search.

    ${fieldsSearched}

    `; + if (hits.length === 0) + return `

    No node matches ${escapeHtml(term)}

    ${fieldsSearched}

    `; + const header = `

    ${escapeHtml(term)}: ${plural(page.total, "node")} and ${plural(page.entryTotal, "entry", "entries")} match. ${fieldsSearched}

    `; + return ( + header + + page.shown + .map(({ record, matchedIn, entries }) => { + const heading = record.title ?? record.label ?? record.id; + const title = + record.page === null + ? codeSpans(heading) + : link(record.page, heading, codeSpans(heading)); + const descriptor = + record.nodeType === "Primitive" + ? `

    Spec · ${escapeHtml(record.specKind ?? "")} · stated ${escapeHtml(record.statedReadiness ?? "unknown")} · floor ${escapeHtml(record.floorReached ?? "none")}

    ` + : `

    ${nodeWords[record.nodeType]}

    `; + const location = + record.file === undefined + ? "" + : `${record.file}${record.line === undefined ? "" : `:${String(record.line)}`}`; + const unit = + record.nodeType === "Anchor" || record.nodeType === "CodeNode" + ? `

    ${escapeHtml(location)}

      ${record.bindings.map((binding) => `
    • ${escapeHtml(binding.edge)} ${link(pagePath(binding.specId) ?? "", binding.specId)}
    • `).join("")}
    ` + : ""; + const entryList = + entries.length === 0 + ? "" + : `
      ${entries.map((entry) => `
    • ${link(entryUrl(record, entry), entry.key ?? entry.entry ?? entry.section, `${escapeHtml(entry.key ?? entry.entry ?? entry.section)}`)} · ${escapeHtml(sectionTitle(entry.section))} · ${entry.line === null ? "line not recorded" : `line ${String(entry.line)}`} · ${escapeHtml(excerpt(entry.text, term))}
    • `).join("")}
    `; + return `

    ${title}

    ${escapeHtml(record.id)}${descriptor}

    Matched in: ${escapeHtml(matchedIn.map(fieldLabel).join(", "))}

    ${unit}${entryList}
    `; + }) + .join("") + ); +} + +export function registerSearchResults(): void { + class SearchResults extends globalThis.HTMLElement { + private index: SearchIndex = { records: [] }; + private term = ""; + private limit = 50; + private input?: HTMLInputElement; + private results?: HTMLDivElement; + private more?: HTMLButtonElement; + private status?: HTMLOutputElement; + private readonly onInput = (): void => { + this.setTerm(this.input?.value ?? "", true); + }; + + private readonly onHash = (): void => { + this.setTerm(searchTerm(this.ownerDocument.location.hash), false); + }; + + private setTerm(term: string, updateFragment: boolean): void { + this.term = term; + this.limit = 50; + if (this.input) this.input.value = term; + if (updateFragment) + this.ownerDocument.defaultView?.history.replaceState(null, "", searchFragment(term)); + this.render(); + } + + private render(): void { + const hits = searchRecords(this.index, this.term); + const page = resultPage(hits, this.limit); + if (this.results) this.results.innerHTML = renderResults(hits, this.term, this.limit); + if (this.more) this.more.hidden = page.remaining === 0; + if (this.status) { + this.status.textContent = + this.term.trim().length === 0 + ? "Type a word to search." + : hits.length === 0 + ? `No node matches ${this.term}` + : `Showing ${String(page.shown.length)} of ${plural(page.total, "node")}`; + this.status.className = page.total > 50 ? "" : "visually-hidden"; + } + } + + connectedCallback(): void { + try { + const data = JSON.parse( + this.ownerDocument.getElementById("sdp-data")?.textContent ?? "{}", + ) as Partial; + this.index = Array.isArray(data.records) ? (data as SearchIndex) : { records: [] }; + } catch { + this.index = { records: [] }; + } + if (!this.results) { + this.input = + this.ownerDocument.querySelector('sdp-search input[name="q"]') ?? + undefined; + const form = this.input ? undefined : this.ownerDocument.createElement("form"); + if (form) { + const label = this.ownerDocument.createElement("label"); + label.textContent = "Search the corpus "; + this.input = this.ownerDocument.createElement("input"); + this.input.type = "search"; + this.input.name = "q"; + label.append(this.input); + const submit = button(this, "Search"); + submit.type = "submit"; + form.append(label, submit); + form.addEventListener("submit", (event) => { + event.preventDefault(); + this.setTerm(this.input?.value ?? "", true); + }); + } + this.results = this.ownerDocument.createElement("div"); + this.results.className = "search-results"; + this.more = button(this, "Show next fifty"); + this.more.addEventListener("click", () => { + this.limit += 50; + this.render(); + }); + this.replaceChildren(...(form ? [form] : [])); + this.status = liveOutput(this); + this.append(this.results, this.more); + } + this.input?.addEventListener("input", this.onInput); + this.ownerDocument.defaultView?.addEventListener("hashchange", this.onHash); + this.onHash(); + } + + disconnectedCallback(): void { + this.input?.removeEventListener("input", this.onInput); + this.ownerDocument.defaultView?.removeEventListener("hashchange", this.onHash); + } + } + if (!globalThis.customElements.get("sdp-search-results")) + globalThis.customElements.define("sdp-search-results", SearchResults); +} diff --git a/src/studio-elements/search.ts b/src/studio-elements/search.ts new file mode 100644 index 00000000..e9b5768c --- /dev/null +++ b/src/studio-elements/search.ts @@ -0,0 +1,47 @@ +export function searchFragment(term: string): string { + return `#q=${encodeURIComponent(term)}`; +} + +export function searchTerm(fragment: string): string { + if (!fragment.startsWith("#q=")) return ""; + try { + return decodeURIComponent(fragment.slice(3)); + } catch { + return ""; + } +} + +export function searchUrl(root: string, term: string): string { + return `${root}search.html${searchFragment(term)}`; +} + +/** + * Uses the page-root attribute when the native form submits. + * + * @sdpAnchor impl:protocol.studio-search-form + * @sdpSatisfies spec:consumers.spec-studio.page-elements + * @sdpComponent component:protocol.studio-elements + * @sdpRole utility + */ +export function registerSearch(): void { + class Search extends globalThis.HTMLElement { + private readonly submit = (event: Event): void => { + event.preventDefault(); + const input = this.querySelector( + 'input[name="q"], input[type="search"], input[type="text"], input:not([type])', + ); + this.ownerDocument.defaultView?.location.assign( + searchUrl(this.getAttribute("page-root") ?? "", input?.value ?? ""), + ); + }; + + connectedCallback(): void { + this.addEventListener("submit", this.submit); + } + disconnectedCallback(): void { + this.removeEventListener("submit", this.submit); + } + } + if (!globalThis.customElements.get("sdp-search")) + globalThis.customElements.define("sdp-search", Search); +} diff --git a/src/studio-elements/stamp.ts b/src/studio-elements/stamp.ts new file mode 100644 index 00000000..2b696b35 --- /dev/null +++ b/src/studio-elements/stamp.ts @@ -0,0 +1,56 @@ +export function stampUrl(protocol: string, root: string): string | null { + return protocol === "http:" || protocol === "https:" ? `${root}stamp.json` : null; +} + +export function stampText(stamp: unknown): string { + if (typeof stamp !== "object" || stamp === null) return ""; + const value = stamp as Record; + if ( + typeof value.corpusCommit !== "string" || + typeof value.engine !== "string" || + typeof value.builtAt !== "string" + ) + return ""; + return `Corpus ${value.corpusCommit} · ${value.engine} · Built ${value.builtAt}`; +} + +/** + * Checks the document's protocol before requesting the optional stamp. + * + * @sdpAnchor impl:protocol.studio-stamp + * @sdpSatisfies spec:consumers.spec-studio.page-elements + * @sdpComponent component:protocol.studio-elements + * @sdpRole utility + */ +export function registerStamp(): void { + class Stamp extends globalThis.HTMLElement { + private generation = 0; + + connectedCallback(): void { + const generation = ++this.generation; + const url = stampUrl( + this.ownerDocument.location.protocol, + this.getAttribute("page-root") ?? "", + ); + this.textContent = ""; + if (url === null) return; + void globalThis + .fetch(url) + .then(async (response) => { + if (!response.ok) return; + const data: unknown = await response.json(); + if (generation === this.generation && this.isConnected) + this.textContent = stampText(data); + }) + .catch(() => { + /* An absent optional stamp leaves no text. */ + }); + } + + disconnectedCallback(): void { + this.generation++; + } + } + if (!globalThis.customElements.get("sdp-stamp")) + globalThis.customElements.define("sdp-stamp", Stamp); +} diff --git a/src/validate/contracts.ts b/src/validate/contracts.ts index 1de15876..a5c589a4 100644 --- a/src/validate/contracts.ts +++ b/src/validate/contracts.ts @@ -53,3 +53,17 @@ export interface Validator { readonly family: ValidatorFamily; validate(input: TInput): ValidationReport; } + +/** Corpus rules share diagnostic fields without joining a Protocol validator family. */ +export interface CorpusFinding extends Omit { + readonly rulesFile: string; + readonly subjectId: string; +} +export interface CorpusFailure { + readonly rulesFile: string; + readonly reason: string; +} +export interface CorpusValidationReport extends ValidationReport { + readonly corpusFindings: readonly CorpusFinding[]; + readonly corpusFailures: readonly CorpusFailure[]; +} diff --git a/src/validate/validators.ts b/src/validate/validators.ts index 87adb892..c8b982e7 100644 --- a/src/validate/validators.ts +++ b/src/validate/validators.ts @@ -1281,7 +1281,7 @@ function proseMentionTexts(node: PrimitiveNode): readonly MentionText[] { * The punctuation is removed after the prefix only, so the prefix's own colon stays. A bare * `spec:`, or a placeholder such as `spec:`, leaves nothing after the prefix and is no mention. */ -function mentionTokens(text: string): readonly string[] { +export function mentionTokens(text: string): readonly string[] { return [...text.replace(MARKDOWN_ESCAPE, "$1").matchAll(MENTION_PATTERN)] .map((match) => match[0].slice("spec:".length).replace(MENTION_TRAILING_PUNCTUATION, "")) .filter((rest) => rest.length > 0) @@ -1347,7 +1347,10 @@ function backingPairs(graph: GraphSchema): ReadonlySet { function mentionLine(graph: GraphSchema, spec: string, path: string): number | undefined { const entry = path + .replace(/^behavior\.rules(?=\[\d+\]$)/u, "rule") + .replace(/^behavior\.flows(?=\[\d+\]$)/u, "flow") .replace(/^intent\.openQuestions/u, "question") + .replace(/^(question\[\d+\])\.question$/u, "$1") .replace(/\.links\[\d+\]$/u, ".links"); return graph.locations?.find((row) => row.spec === spec && row.entry === entry)?.line; } diff --git a/test/architecture-recipes.test.ts b/test/architecture-recipes.test.ts new file mode 100644 index 00000000..8149b029 --- /dev/null +++ b/test/architecture-recipes.test.ts @@ -0,0 +1,305 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; +import { describe, expect, it } from "vitest"; +import { recipeFiles } from "../src/cli/build-artifacts.js"; +import { compileBody } from "../src/cli/q-command.js"; +import { createReader, schemaVersion, validateGraph } from "../src/index.js"; +import type { GraphSchema, GraphNode, GraphEdge } from "../src/index.js"; + +const walkRecipeAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.architecture-walk-recipe"), + label: "asserts realization of the shipped architecture walk recipe", + satisfies: ref("spec:validation.architecture-constraints"), +}); +void walkRecipeAnchor; +const comparisonRecipeAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.planned-components-recipe"), + label: "asserts realization of the shipped planned against realized components recipe", + satisfies: ref("spec:model.planned-architecture"), +}); +void comparisonRecipeAnchor; + +const catalog = fileURLToPath(new URL("../docs/agent-surface/recipes.md", import.meta.url)); +async function recipe(ordinal: number, graph: GraphSchema, params = {}) { + const source = recipeFiles(readFileSync(catalog, "utf8")).find((row) => row.ordinal === ordinal); + if (source === undefined) throw new Error(`missing recipe ${String(ordinal)}`); + return (await compileBody(source.content)( + createReader(graph), + graph, + validateGraph(graph), + params, + )) as Record; +} +type MutableGraph = Omit & { + nodes: GraphNode[]; + edges: GraphEdge[]; +}; +function probe(): MutableGraph { + return { + schemaVersion, + nodes: [ + { + id: "component:probe.a", + nodeType: "CodeNode", + claim: "anchored", + file: "a.ts", + line: 1, + layer: "domain", + context: "platform", + role: "service", + }, + { + id: "component:probe.b", + nodeType: "CodeNode", + claim: "anchored", + file: "b.ts", + line: 1, + layer: "infrastructure", + context: "orders", + }, + { id: "impl:probe.a", nodeType: "CodeNode", claim: "anchored", file: "a.ts", line: 2 }, + { id: "impl:probe.b", nodeType: "CodeNode", claim: "anchored", file: "b.ts", line: 2 }, + { id: "impl:probe.bare", nodeType: "CodeNode", claim: "anchored", file: "c.ts", line: 1 }, + ], + edges: [ + { from: "impl:probe.a", to: "component:probe.a", type: "memberOf", claim: "anchored" }, + { from: "impl:probe.b", to: "component:probe.b", type: "memberOf", claim: "anchored" }, + { from: "impl:probe.a", to: "impl:probe.b", type: "uses", claim: "anchored" }, + ], + }; +} +function design(graph: MutableGraph, entries: Record, id = "spec:probe.design") { + graph.nodes.push({ + id, + nodeType: "Primitive", + claim: "declared", + specKind: "model", + altitude: "story", + readiness: "idea", + title: "Probe", + file: "probe.sdp.md", + sections: { intent: { outcome: "Probe" }, design: { id: "component:probe.a", ...entries } }, + }); + graph.edges.push({ + from: "component:probe.a", + to: id, + type: "satisfies", + claim: "anchored", + }); +} +describe("architecture walk", () => { + it.each([ + {}, + { forbidden: [{ from: "wrong", to: "domain" }] }, + { isolated: ["Bad"] }, + { isolated: [], severity: "info" }, + { forbidden: false }, + { isolated: "platform" }, + { forbidden: null }, + { isolated: null }, + { isolated: [], severity: null }, + ])("diagnoses malformed parameters %j", async (params) => { + expect(await recipe(42, probe(), params)).toHaveProperty("diagnostics"); + }); + it("answers unknown names before checking values", async () => { + expect(await recipe(42, probe(), { typo: 1, forbidden: false })).toEqual({ + unknownParams: ["typo"], + accepts: ["forbidden", "isolated", "severity"], + }); + }); + it("resolves members, deduplicates rules and sorts both breaches", async () => { + const pair = { from: "domain", to: "infrastructure" }; + const result = await recipe(42, probe(), { + forbidden: [pair, pair], + isolated: ["platform", "platform"], + severity: "warning", + }); + expect(result.totals).toEqual({ usesEdges: 1, resolved: 1, findings: 2, unclassified: 0 }); + expect(result.findings).toMatchObject([ + { + validatorId: "forbidden-layer", + severity: "warning", + subjectId: "impl:probe.a", + relatedId: "impl:probe.b", + fromComponent: { id: "component:probe.a" }, + }, + { validatorId: "isolated-context" }, + ]); + }); + it("sorts findings and unclassified edges independently of insertion order", async () => { + const graph = probe(); + graph.nodes.push({ + id: "impl:probe.0", + nodeType: "CodeNode", + claim: "anchored", + file: "a.ts", + line: 3, + }); + graph.edges.push( + { from: "impl:probe.a", to: "impl:probe.bare", type: "uses", claim: "anchored" }, + { from: "impl:probe.0", to: "component:probe.a", type: "memberOf", claim: "anchored" }, + { from: "impl:probe.0", to: "impl:probe.b", type: "uses", claim: "anchored" }, + { from: "impl:probe.0", to: "impl:probe.bare", type: "uses", claim: "anchored" }, + ); + const result = await recipe(42, graph, { + forbidden: [{ from: "domain", to: "infrastructure" }], + }); + expect((result.findings as { subjectId: string }[]).map((row) => row.subjectId)).toEqual([ + "impl:probe.0", + "impl:probe.a", + ]); + expect((result.unclassified as { from: string }[]).map((row) => row.from)).toEqual([ + "impl:probe.0", + "impl:probe.a", + ]); + }); + it("reports missing components and attributes apart, ignoring same-component uses", async () => { + const graph = probe(); + const node = graph.nodes[1]; + if (node?.nodeType === "CodeNode") { + const { layer: _layer, ...bare } = node; + void _layer; + graph.nodes[1] = bare; + } + graph.edges.push( + { from: "impl:probe.a", to: "impl:probe.bare", type: "uses", claim: "anchored" }, + { from: "impl:probe.a", to: "component:probe.a", type: "uses", claim: "anchored" }, + ); + const result = await recipe(42, graph, { + forbidden: [{ from: "domain", to: "infrastructure" }], + }); + expect(result.totals).toEqual({ usesEdges: 3, resolved: 2, findings: 0, unclassified: 2 }); + expect(result.unclassified).toMatchObject([ + { to: "impl:probe.b", missing: ["layer"] }, + { to: "impl:probe.bare", toComponent: null, missing: ["component"] }, + ]); + expect((await recipe(42, graph, { forbidden: [], isolated: [] })).unclassified).toEqual([]); + }); +}); +describe("planned components", () => { + it.each([2, 3])( + "reads matching %s-backtick opening spans for every component design value", + async (length) => { + const graph = probe(); + const ticks = "`".repeat(length); + const span = (text: string) => `${ticks}${text}${ticks} because this is the plan`; + design(graph, { + id: span("component:probe.future"), + layer: span("domain"), + context: span("platform"), + role: span("service"), + uses: span("none"), + }); + graph.edges.pop(); + const result = await recipe(43, graph); + expect(result.totals).toMatchObject({ designs: 1, unrealized: 1, unreadable: 0 }); + expect(result.unrealized).toEqual([ + { + spec: "spec:probe.design", + componentId: "component:probe.future", + anchorExists: false, + layer: "domain", + context: "platform", + role: "service", + uses: [], + }, + ]); + }, + ); + it("refuses any parameter name", async () => { + expect(await recipe(43, probe(), { typo: 1 })).toEqual({ + unknownParams: ["typo"], + accepts: [], + }); + }); + it("compares opening spans, role, context and the uses set", async () => { + const graph = probe(); + design(graph, { layer: "`domain` because", role: "decider", context: "orders", uses: "none" }); + const result = await recipe(43, graph); + expect(result.totals).toEqual({ + designs: 1, + realized: 1, + matching: 0, + unrealized: 0, + differing: 3, + unreadable: 0, + }); + expect(result.differing).toEqual([ + { + spec: "spec:probe.design", + componentId: "component:probe.a", + field: "context", + planned: "orders", + realized: "platform", + }, + { + spec: "spec:probe.design", + componentId: "component:probe.a", + field: "role", + planned: "decider", + realized: "service", + }, + { + spec: "spec:probe.design", + componentId: "component:probe.a", + field: "uses", + planned: [], + realized: ["component:probe.b"], + }, + ]); + }); + it("sorts differing fields and unrealized designs independently of insertion order", async () => { + const graph = probe(); + design(graph, { layer: "application", context: "orders" }); + design(graph, { id: "component:probe.z" }, "spec:probe.z"); + graph.edges.pop(); + design(graph, { id: "component:probe.0" }, "spec:probe.0"); + graph.edges.pop(); + const result = await recipe(43, graph); + expect((result.differing as { field: string }[]).map((row) => row.field)).toEqual([ + "context", + "layer", + ]); + expect((result.unrealized as { spec: string }[]).map((row) => row.spec)).toEqual([ + "spec:probe.0", + "spec:probe.z", + ]); + }); + it("deduplicates padded uses and skips unstated fields", async () => { + const graph = probe(); + design(graph, { uses: " component:probe.b , component:probe.b " }); + expect((await recipe(43, graph)).totals).toMatchObject({ matching: 1, differing: 0 }); + }); + it("does not count unreadable realized designs as matching", async () => { + const graph = probe(); + design(graph, { layer: "domain because", uses: "impl:probe.b" }); + expect((await recipe(43, graph)).totals).toMatchObject({ + matching: 0, + unreadable: 2, + differing: 0, + }); + }); + it("lists an existing but unbound anchor and preserves unstated uses", async () => { + const graph = probe(); + design(graph, { layer: "domain" }); + graph.edges.pop(); + expect((await recipe(43, graph)).unrealized).toEqual([ + { + spec: "spec:probe.design", + componentId: "component:probe.a", + anchorExists: true, + layer: "domain", + role: null, + context: null, + uses: null, + }, + ]); + }); +}); + +it("recognizes component IDs with the stable-ID path grammar", async () => { + const graph = probe(); + design(graph, { id: "component:Probe.1kernel" }); + expect((await recipe(43, graph)).totals).toMatchObject({ designs: 1, unrealized: 1 }); +}); diff --git a/test/check-self-hosting-gates.test.ts b/test/check-self-hosting-gates.test.ts index e5fd9f22..e2d9394e 100644 --- a/test/check-self-hosting-gates.test.ts +++ b/test/check-self-hosting-gates.test.ts @@ -76,11 +76,11 @@ afterEach(() => { }); describe("the self-hosting records gate", () => { - it("discovers plan 44 as the current primary plan", () => { - // re-measured after plan 44's record commit + it("discovers plan 45 as the current primary plan", () => { + // re-measured after plan 45's opening commit expect(currentPlan).toMatchObject({ - number: 44, - name: "44-whole-corpus-refresh.md", + number: 45, + name: "45-design-at-the-adopters-scale.md", }); }); diff --git a/test/cli.test.ts b/test/cli.test.ts index a87ccba1..dac9e09a 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,5 +1,6 @@ import { execFileSync, spawn } from "node:child_process"; import { + copyFileSync, cpSync, existsSync, mkdirSync, @@ -93,17 +94,22 @@ function materializeSelfHostingCopy(): string { // Copied relative builder modules are outside the Protocol's trusted source checkout. Use // the public anchors import in this isolated corpus so both forms retain their bindings. - for (const entry of readdirSync(join(root, "src"), { recursive: true, withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith(".ts")) continue; - const path = join(entry.parentPath, entry.name); - const source = readFileSync(path, "utf8"); - writeFileSync( - path, - source.replace( - /from "(?:\.\.?\/)+(?:model\/)?(?:ids|code-anchor)\.js"/gu, - 'from "@libar-dev/software-delivery-protocol/anchors"', - ), - ); + for (const surface of ["src", "test"]) { + for (const entry of readdirSync(join(root, surface), { + recursive: true, + withFileTypes: true, + })) { + if (!entry.isFile() || !entry.name.endsWith(".ts")) continue; + const path = join(entry.parentPath, entry.name); + const source = readFileSync(path, "utf8"); + writeFileSync( + path, + source.replace( + /from "(?:\.\.?\/)+(?:src\/)?(?:model\/)?(?:ids|anchors|code-anchor)\.js"/gu, + 'from "@libar-dev/software-delivery-protocol/anchors"', + ), + ); + } } return root; @@ -307,12 +313,6 @@ describe("sdp cli", () => { "spec:consumers.validate-watch", "spec:consumers.obsidian-vault.iteration", ], - [ - "specs/consumers/obsidian-vault.sdp.md", - 35, - "spec:consumers.spec-studio", - "spec:consumers.obsidian-vault", - ], [ "specs/consumers/spec-studio.lenses.sdp.md", 30, @@ -321,13 +321,13 @@ describe("sdp cli", () => { ], [ "specs/consumers/spec-studio.lenses.sdp.md", - 33, + 37, "spec:observation.run-evidence", "spec:consumers.spec-studio.lenses", ], [ "specs/consumers/spec-studio.verification-panels.sdp.md", - 26, + 28, "spec:observation.run-evidence", "spec:consumers.spec-studio.verification-panels", ], @@ -343,7 +343,7 @@ describe("sdp cli", () => { ); } // Re-measured from the refreshed carriers and their retained warning pairs. - expect(capture.readStdout()).toContain("validate: 0 errors · 7 warnings"); + expect(capture.readStdout()).toContain("validate: 0 errors · 6 warnings"); expect(readFileSync(join(root, "generated", "graph.json"), "utf8")).toContain( '"id": "pack:self-hosting-v1"', ); @@ -2510,6 +2510,81 @@ describe("sdp --version", () => { } }); + it("builds commit metadata from a substituted git archive extracted without .git", () => { + const scratch = mkdtempSync(join(tmpdir(), "sdp-archive-build-")); + const source = join(scratch, "source"); + const archived = join(scratch, "archived"); + const archive = join(scratch, "source.tar"); + const git = (args: string[]) => + execFileSync("git", args, { + cwd: source, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + try { + mkdirSync(source); + mkdirSync(archived); + for (const file of [".gitattributes", ".git-archive-commit"]) + copyFileSync(join(repoRoot, file), join(source, file)); + mkdirSync(join(source, "docs/agent-surface"), { recursive: true }); + writeFileSync( + join(source, "docs/agent-surface/recipes.md"), + "## 1. One\n\n```js\nreturn 1;\n```\n", + ); + git(["init", "--quiet"]); + git(["add", ".gitattributes", ".git-archive-commit", "docs/agent-surface/recipes.md"]); + git([ + "-c", + "user.name=Archive test", + "-c", + "user.email=archive@example.invalid", + "commit", + "--quiet", + "-m", + "Archive fixture", + ]); + const commit = git(["rev-parse", "HEAD"]).trim(); + git(["archive", "--format=tar", "--output", archive, commit]); + execFileSync("tar", ["-xf", archive, "-C", archived]); + expect(existsSync(join(archived, ".git"))).toBe(false); + expect(readFileSync(join(archived, ".git-archive-commit"), "utf8").trim()).toBe(commit); + writeBuildArtifacts({ root: archived, outDir: join(archived, "dist") }); + expect(JSON.parse(readFileSync(join(archived, "dist/cli/build-info.json"), "utf8"))).toEqual({ + commit, + }); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); + + it("uses only a full archive commit when git gives none, keeping git first", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-archive-info-")); + const noGit = () => { + throw new Error("no git"); + }; + try { + for (const text of [fullCommit, "a".repeat(64)]) { + writeFileSync(join(root, ".git-archive-commit"), `${text}\n`); + expect(readBuildCommit(root, noGit)).toBe(text); + expect(readBuildCommit(root, () => "not a full commit")).toBe(text); + expect(readBuildCommit(root, () => `${"b".repeat(40)}\n`)).toBe("b".repeat(40)); + } + for (const text of [ + "$Format:%H$", + "cc88076", + `${fullCommit}\nextra`, + fullCommit.toUpperCase(), + ]) { + writeFileSync(join(root, ".git-archive-commit"), text); + expect(readBuildCommit(root, noGit)).toBe("unknown"); + } + rmSync(join(root, ".git-archive-commit")); + expect(readBuildCommit(root, noGit)).toBe("unknown"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("answers from the compiled CLI with the commit its build recorded beside it", () => { const compiledCli = join(repoRoot, "dist", "cli", "sdp.js"); const record = join(repoRoot, "dist", "cli", BUILD_INFO_FILE); diff --git a/test/concept-match.test.ts b/test/concept-match.test.ts new file mode 100644 index 00000000..a4c0d934 --- /dev/null +++ b/test/concept-match.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; + +import { createReader, extract } from "../src/index.js"; +import { ref, specTest, testAnchorId } from "../src/anchors.js"; +import { matchConcept } from "../src/reader/concept-match.js"; +import { searchRecords, type SearchRecord } from "../src/studio-elements/search-results.js"; + +const anchor = specTest({ + id: testAnchorId("test:protocol.studio-concept-match"), + verifies: ref("spec:consumers.spec-studio.search"), +}); +void anchor; + +const corpora = [ + extract({ + root: process.cwd(), + exclude: ["explorations", "examples", "test/fixtures/import/parity"], + }).graph, + extract({ root: `${process.cwd()}/examples/checkout-v1` }).graph, +]; + +describe("the concept matcher shared by the reader and Studio", () => { + it("ships a standalone matcher module with no Node dependency", () => { + const module = readFileSync( + new URL("../dist/reader/concept-match.js", import.meta.url), + "utf8", + ); + expect(module).not.toMatch(/\b(?:import|require)\s*(?:\(|[\s\S]*?from\s*)["']node:/u); + expect(module).toContain("matchConcept"); + }); + it.each(["order", "ready", "sentinel", "POST /ORDERS", "inventorySnapshot", "given", "", " "])( + "keeps reader results and their order for %j on both corpora", + (term) => { + for (const graph of corpora) { + const reader = createReader(graph); + expect(matchConcept(graph.nodes, term)).toEqual(reader.findByConcept(term)); + const records: SearchRecord[] = graph.nodes.map((node) => ({ + ...node, + page: null, + bindings: [], + entries: [], + })); + const baseIds = reader.findByConcept(term).map((match) => match.id); + const results = searchRecords({ records }, term); + expect(results.slice(0, baseIds.length).map((hit) => hit.record.id)).toEqual(baseIds); + expect(results.slice(baseIds.length).map((hit) => hit.record.id)).toEqual( + results + .slice(baseIds.length) + .map((hit) => hit.record.id) + .sort(), + ); + } + }, + ); + + it("pins ranking and the fields the reader scans independently of its caller", () => { + expect( + matchConcept( + [ + { + id: "spec:z.needle", + nodeType: "Primitive", + title: "Needle", + sections: { design: { needle: 42 } }, + }, + { id: "spec:a", nodeType: "Primitive", title: "Needle" }, + { id: "impl:a", nodeType: "CodeNode", label: "Needle" }, + { id: "pack:a", nodeType: "Pack", framing: "Needle" }, + { + id: "spec:b", + nodeType: "Primitive", + narrative: "Needle", + sections: { intent: { outcome: "Needle" }, model: { terms: { needle: 42 } } }, + }, + ], + " NEEDLE ", + ), + ).toEqual([ + { id: "spec:z.needle", nodeType: "Primitive", title: "Needle", matchedIn: ["id", "title"] }, + { id: "spec:a", nodeType: "Primitive", title: "Needle", matchedIn: ["title"] }, + { id: "impl:a", nodeType: "CodeNode", matchedIn: ["label"] }, + { id: "pack:a", nodeType: "Pack", matchedIn: ["framing"] }, + { + id: "spec:b", + nodeType: "Primitive", + matchedIn: ["narrative", "sections.intent", "sections.model"], + }, + ]); + }); + + it("appends only coined-key and file matches, after concept matches", () => { + const records: SearchRecord[] = [ + { + id: "spec:z", + nodeType: "Primitive", + page: "specs/z.html", + title: "Needle", + bindings: [], + entries: [], + }, + { + id: "spec:b", + nodeType: "Primitive", + page: "specs/b.html", + sections: { design: { needleKey: 42 } }, + bindings: [], + entries: [ + { + section: "design", + entry: "needleKey", + key: "needleKey", + text: "42", + address: "spec:b#design.needleKey", + line: 8, + }, + ], + }, + { + id: "impl:a", + nodeType: "CodeNode", + page: null, + file: "src/needle.ts", + bindings: [], + entries: [], + }, + { + id: "spec:c", + nodeType: "Primitive", + page: null, + sections: { intent: { needle: 42 } }, + bindings: [], + entries: [], + }, + ]; + expect( + searchRecords({ records }, "needle").map((hit) => [hit.record.id, hit.matchedIn]), + ).toEqual([ + ["spec:z", ["title"]], + ["impl:a", ["file"]], + ["spec:b", ["key"]], + ]); + expect(searchRecords({ records }, " ")).toEqual([]); + }); +}); diff --git a/test/corpus-rules.test.ts b/test/corpus-rules.test.ts new file mode 100644 index 00000000..45afe7aa --- /dev/null +++ b/test/corpus-rules.test.ts @@ -0,0 +1,514 @@ +import { spawn } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { parseBuildArgs } from "../src/cli/build-args.js"; +import { runValidateRules } from "../src/cli/validate-view-command.js"; +import type { ValidateWatchEvent } from "../src/cli/validate-watch.js"; +import { runSdpCli, SDP_HELP_TEXT } from "../src/cli/sdp.js"; +import { createCaptureOutput } from "./helpers/cli-capture.js"; + +const roots: string[] = []; +function probe() { + const root = mkdtempSync(join(tmpdir(), "sdp-corpus-")); + roots.push(root); + for (const [name, relation] of [ + ["first", "{}"], + ["second", "{refines: spec:probe.first}"], + ] as const) { + writeFileSync( + join(root, `${name}.sdp.md`), + `---\nid: spec:probe.${name}\nkind: behavior\naltitude: story\nreadiness: idea\nrelations: ${relation}\n---\n# Probe ${name}\n\n## Intent\n- outcome: Probe the rules runner.\n`, + ); + } + return root; +} +function rules(root: string, name: string, body: string) { + const path = join(root, name); + writeFileSync(path, body); + return path; +} +async function validate(root: string, ...args: string[]) { + const capture = createCaptureOutput(); + const code = await runSdpCli(["validate", root, ...args], capture.output); + return { code, stdout: capture.readStdout(), stderr: capture.readStderr() }; +} +const finding = (severity = "error") => + `({ validatorId: "probe-rule", subjectId: "probe-subject", severity: "${severity}", message: "probe finding" })`; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("corpus rule arguments", () => { + it("documents repeatable rules, per-file parameters and check-clean", () => { + expect(SDP_HELP_TEXT).toContain( + "sdp validate [root] [--exclude PATH]... [--check-clean | --watch] [--rules PATH [--params FORM]...]...", + ); + expect(SDP_HELP_TEXT).toContain("--rules combines with --check-clean"); + }); + it.each([ + ["--params", "{}"], + ["--params", "{}", "--rules", "a"], + ["--rules"], + ["--rules", "a", "--rules", "./a"], + ["--rules", "a", "--params", '{"x":1}', "--params", '{"x":2}'], + ["--rules", "a", "--params", "[]"], + ["--rules", "a", "--params", "bad"], + ["--rules", "a", "--params", '{"x":1,"x":2}'], + ])("refuses %j before deriving", (...args) => { + const capture = createCaptureOutput(); + expect(parseBuildArgs(args, capture.output, "validate")).toBeUndefined(); + expect(capture.readStderr()).not.toBe(""); + }); + it("binds parameters to the preceding file across other options", () => { + const capture = createCaptureOutput(); + const parsed = parseBuildArgs( + [ + "--rules", + "a", + "--exclude", + "x", + "--params", + '{"x":1}', + "--rules", + "b", + "--params", + '{"x":2}', + ], + capture.output, + "validate", + ); + expect(parsed).toMatchObject({ + rules: [ + { rulesFile: "a", params: { x: 1 } }, + { rulesFile: "b", params: { x: 2 } }, + ], + }); + }); + it.each(["build", "view", "census", "mermaid", "gherkin", "q"])( + "refuses rules on %s", + async (command) => { + const capture = createCaptureOutput(); + expect(await runSdpCli([command, "--rules", "a"], capture.output)).toBe(1); + expect(capture.readStderr()).toContain("--rules"); + }, + ); +}); + +describe("corpus rules on validate", () => { + it("refuses the same rules file through a symlink before deriving", async () => { + const root = probe(); + const file = rules(root, "finding.js", `return { findings: [${finding("warning")}] };`); + const alias = join(root, "finding-alias.js"); + symlinkSync(file, alias); + const result = await validate(root, "--rules", file, "--rules", alias); + expect(result.code).toBe(1); + expect(result.stderr).toContain(`--rules file ${JSON.stringify(alias)} was given twice.`); + expect(result.stdout).toBe(""); + }); + it.each([ + "/etc/passwd", + "../outside.ts", + "src/../../outside.ts", + "src\\\\probe.ts", + "C:/outside.ts", + ])("refuses a finding file outside root-relative POSIX paths: %s", async (file) => { + const root = probe(); + const path = rules( + root, + "bad-path.js", + `return { findings: [{ ...${finding("warning")}, file: ${JSON.stringify(file)}, line: 1 }] };`, + ); + const result = await validate(root, "--rules", path); + expect(result.code).toBe(1); + expect(result.stderr).toContain("finding 1 has no lawful file"); + expect(result.stdout).toContain("0 errors · 0 warnings · 1 of 1 files failed"); + }); + it.each(["src/probe.ts", "src/../probe.ts"])( + "reports a root-relative finding path: %s", + async (file) => { + const root = probe(); + const path = rules( + root, + "path.js", + `return { findings: [{ ...${finding("warning")}, file: ${JSON.stringify(file)}, line: 1 }] };`, + ); + const result = await validate(root, "--rules", path); + expect(result.code).toBe(0); + expect(result.stderr).toContain(`${file}:1`); + }, + ); + it.each(["error", "warning"])( + "reports a %s finding apart from Protocol findings", + async (severity) => { + const root = probe(); + const file = rules(root, "rules", `return { findings: [${finding(severity)}] };`); + const result = await validate(root, "--rules", file, "--check-clean"); + expect(result.code).toBe(severity === "error" ? 1 : 0); + expect(result.stdout).toContain( + "validate: 0 errors · 0 warnings (conformance + honesty over the one graph)\ncorpus rules:", + ); + expect(result.stdout).toContain( + `${severity === "error" ? "1 errors · 0 warnings" : "0 errors · 1 warnings"} · 0 of 1 files failed`, + ); + expect(result.stderr).toContain(`corpus rules ${file}\n[${severity}] probe-rule`); + }, + ); + it.each([ + ["throw new Error('two\\nlines')", "threw: two lines"], + ["return {", "does not parse at line 1, column"], + ["return { diagnostics: ['bad'] }", 'returned no findings array; diagnostics: ["bad"]'], + [ + "return { unknownParams: ['typo'], accepts: [] }", + 'returned no findings array; unknownParams: ["typo"]; accepts: []', + ], + [ + "return { findings: [{ validatorId: 'a', severity: 'warning', message: 'x' }] }", + "finding 1 has no lawful subjectId", + ], + [ + `return { findings: [{ ...${finding()}, severity: 'info' }] }`, + "finding 1 has no lawful severity", + ], + [ + `return { findings: [{ ...${finding()}, validatorId: 'a/b' }] }`, + "finding 1 has no lawful validatorId", + ], + [`return { findings: [{ ...${finding()}, line: 1 }] }`, "finding 1 has no lawful line"], + [ + `return { findings: [{ ...${finding()}, relatedId: 1 }] }`, + "finding 1 has no lawful relatedId", + ], + [`return { findings: [{ ...${finding()}, path: 1 }] }`, "finding 1 has no lawful path"], + [`return { findings: [{ ...${finding()}, file: '' }] }`, "finding 1 has no lawful file"], + ])("fails a broken file and continues: %s", async (body, reason) => { + const root = probe(); + const first = rules(root, "broken", body); + const second = rules(root, "ok", `return { findings: [${finding("warning")}] };`); + const result = await validate(root, "--rules", first, "--rules", second); + expect(result.code).toBe(1); + expect(result.stderr).toContain(`failed: ${reason}`); + expect(result.stderr.indexOf(first)).toBeLessThan(result.stderr.indexOf(second)); + expect(result.stdout).toContain("0 errors · 1 warnings · 1 of 2 files failed"); + }); + it("fails an unreadable file", async () => { + const root = probe(); + expect((await validate(root, "--rules", join(root, "missing"))).stderr).toContain( + "cannot be read (", + ); + }); + it("discards every finding of a malformed answer", async () => { + const root = probe(); + const file = rules(root, "rules", `return { findings: [${finding()}, {}] };`); + const result = await validate(root, "--rules", file); + expect(result.stderr).toContain("finding 2 has no lawful validatorId"); + expect(result.stderr).not.toContain("probe finding"); + expect(result.stdout).toContain("0 errors · 0 warnings · 1 of 1 files failed"); + }); + it("clones the graph and report separately for each file and leaves written bytes whole", async () => { + const root = probe(); + const first = rules( + root, + "first.rules", + "graph.nodes.length = 0; graph.edges.length = 0; report.findings.push({ severity: 'error' }); return { findings: [] };", + ); + const second = rules( + root, + "second.rules", + "return { findings: graph.nodes.length === 2 && graph.edges.length === 1 && report.findings.length === 0 && !('corpusFindings' in report) && !('corpusFailures' in report) ? [] : [{ validatorId: 'changed', subjectId: 'probe', severity: 'error', message: 'changed' }] };", + ); + const result = await validate(root, "--rules", first, "--rules", second); + expect(result.code).toBe(0); + expect( + (JSON.parse(readFileSync(join(root, "generated/graph.json"), "utf8")) as { nodes: unknown[] }) + .nodes, + ).toHaveLength(2); + }); + it("gives each file only its own parameters, including JSON files and keyed values", async () => { + const root = probe(); + const json = rules(root, "params.json", '{"x":1}'); + const value = rules(root, "value.json", "[2]"); + const first = rules( + root, + "first.rules", + "return { findings: params.x === 1 && params.list[0] === 2 ? [] : [{ validatorId: 'params', subjectId: 'probe', severity: 'error', message: 'wrong' }] }; ", + ); + const second = rules( + root, + "second.rules", + "return { findings: Object.keys(params).length === 0 ? [] : [{ validatorId: 'params', subjectId: 'probe', severity: 'error', message: 'leaked' }] }; ", + ); + expect( + ( + await validate( + root, + "--rules", + first, + "--params", + `@${json}`, + "--params", + `list=@${value}`, + "--rules", + second, + ) + ).code, + ).toBe(0); + }); + it("keeps the ordinary validate output unchanged and q report free of corpus data", async () => { + const root = probe(); + expect((await validate(root)).stdout).not.toContain("corpus rules"); + const capture = createCaptureOutput(); + expect( + await runSdpCli( + [ + "q", + "return ['corpusFindings', 'corpusFailures'].filter(key => key in report)", + "--root", + root, + "--json", + ], + capture.output, + ), + ).toBe(0); + expect(JSON.parse(capture.readStdout())).toEqual([]); + }); + it("does not run rules when the graph does not derive", async () => { + const root = probe(); + writeFileSync(join(root, "bad.sdp.md"), "---\nid: invalid\n---\n# Bad\n"); + const file = rules(root, "rules", "throw new Error('should not run');"); + const result = await validate(root, "--rules", file); + expect(result.code).toBe(1); + expect(result.stdout.trim().split("\n").at(-1)).toBe( + "corpus rules: not run, the graph did not derive", + ); + expect(result.stdout).not.toContain("validate:"); + expect(result.stderr).not.toContain("should not run"); + }); +}); + +it("exposes corpus data only on the aggregate validate outcome", async () => { + const root = probe(); + const capture = createCaptureOutput(); + const first = rules( + root, + "ok", + `return { findings: [{ ...${finding("warning")}, relatedId: "other", path: "design.layer", extra: 1 }] };`, + ); + const second = rules(root, "bad", "return {}"); + const parsed = parseBuildArgs( + [root, "--rules", first, "--rules", second], + capture.output, + "validate", + ); + if (parsed === undefined) throw new Error(capture.readStderr()); + const outcome = await runValidateRules(parsed, capture.output); + expect(outcome.report).toMatchObject({ + findings: [], + corpusFindings: [{ rulesFile: first, validatorId: "probe-rule" }], + corpusFailures: [{ rulesFile: second, reason: "returned no findings array" }], + }); + expect(outcome.report?.corpusFindings[0]).toEqual({ + rulesFile: first, + validatorId: "probe-rule", + subjectId: "probe-subject", + severity: "warning", + message: "probe finding", + relatedId: "other", + path: "design.layer", + }); +}); +it("awaits bodies in file order and keeps a Protocol error fatal", async () => { + const root = probe(); + writeFileSync( + join(root, "dangling.sdp.md"), + "---\nid: spec:probe.dangling\nkind: behavior\naltitude: story\nreadiness: idea\nrelations: {refines: spec:probe.missing}\n---\n# Dangling\n\n## Intent\n- outcome: Probe a Protocol error.\n", + ); + const first = rules( + root, + "one", + `await Promise.resolve(); return { findings: [${finding("warning")}] };`, + ); + const second = rules(root, "two", `return { findings: [${finding("warning")}] };`); + const result = await validate(root, "--rules", first, "--rules", second); + expect(result.code).toBe(1); + expect(result.stderr.indexOf("conformance/")).toBeLessThan(result.stderr.indexOf("corpus rules")); + expect(result.stderr.indexOf(`corpus rules ${first}`)).toBeLessThan( + result.stderr.indexOf(`corpus rules ${second}`), + ); + expect(result.stdout).toContain("corpus rules: 0 errors · 2 warnings"); +}); + +/** + * @sdpAnchor test:protocol.validate-watch-rules + * @sdpLabel process checks verify operator stop during active corpus rules + * @sdpVerifies spec:consumers.validate-watch + */ +it.skipIf(process.platform === "win32").each([false, true])( + "SIGINT exits 0 during a live rules body, with no later output or rules, even if it can settle: %s", + async (settlesAfterStop) => { + const root = probe(); + const first = rules( + root, + "pending.js", + `const interval = setInterval(() => {}, 1000); +console.log("first rule entered"); +await new Promise(resolve => { + ${settlesAfterStop ? 'process.once("SIGINT", () => setImmediate(resolve));' : ""} +}); +clearInterval(interval); +console.log("first rule resumed"); +return { findings: [] };`, + ); + const second = rules( + root, + "next.js", + 'console.log("second rule ran"); console.error("second rule stderr"); return { findings: [] };', + ); + const child = spawn( + process.execPath, + [ + fileURLToPath(new URL("../dist/cli/sdp.js", import.meta.url)), + "validate", + root, + "--watch", + "--rules", + first, + "--rules", + second, + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => + child.once("close", (code, signal) => { + resolve({ code, signal }); + }), + ); + let stdout = ""; + let stderr = ""; + let entered: (() => void) | undefined; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + if (stdout.includes("first rule entered\n")) entered?.(); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + let startDeadline: ReturnType | undefined; + let stopDeadline: ReturnType | undefined; + try { + await new Promise((resolve, reject) => { + entered = resolve; + startDeadline = setTimeout(() => { + reject(new Error(`first rules body did not start: ${stderr}`)); + }, 10000); + child.once("error", reject); + child.once("close", () => { + reject(new Error(`watch exited before stop: ${stderr}`)); + }); + }); + clearTimeout(startDeadline); + const beforeStop = { stdout, stderr }; + expect(child.kill("SIGINT")).toBe(true); + const stopped = await Promise.race([ + exited, + new Promise((_resolve, reject) => { + stopDeadline = setTimeout(() => { + reject(new Error("watch did not exit within 2s of SIGINT during a rules body")); + }, 2000); + }), + ]); + expect(stopped).toEqual({ code: 0, signal: null }); + expect({ stdout, stderr }).toEqual(beforeStop); + expect(stdout).not.toContain("second rule ran"); + expect(stderr).not.toContain("second rule stderr"); + } finally { + clearTimeout(startDeadline); + clearTimeout(stopDeadline); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + await exited; + } + }, + 15000, +); + +it("rereads rules on the next carrier cycle and ignores a rules-file event", async () => { + const root = probe(); + const file = rules(root, "rules.js", "return { findings: [] };"); + const capture = createCaptureOutput(); + const abort = new AbortController(); + let listener: ((event: ValidateWatchEvent) => void) | undefined; + const completed: number[] = []; + const running = runSdpCli(["validate", root, "--watch", "--rules", file], capture.output, { + abortSignal: abort.signal, + createWatchSource: () => ({ + on: (callback) => { + listener = callback; + }, + onError: () => { + /* This event source never fails. */ + }, + close: () => { + /* No native watch handle is allocated. */ + }, + }), + onWatchCycleComplete: (cycle) => { + completed.push(cycle.exitCode); + if (cycle.index === 1) { + writeFileSync(file, `return { findings: [${finding()}] };`); + listener?.({ type: "change", path: "rules.js" }); + queueMicrotask(() => listener?.({ type: "change", path: "first.sdp.md" })); + } else abort.abort(); + }, + }); + try { + expect(await running).toBe(0); + expect(completed).toEqual([0, 1]); + expect(capture.readStdout()).toContain("corpus rules: 1 errors · 0 warnings"); + } finally { + abort.abort(); + } +}); + +it("gives each watch cycle fresh parameters after a body mutates them", async () => { + const root = probe(); + const file = rules( + root, + "rules.js", + `const findings = params.x === 1 ? [] : [${finding()}]; params.x = 2; return { findings };`, + ); + const capture = createCaptureOutput(); + const abort = new AbortController(); + let listener: ((event: ValidateWatchEvent) => void) | undefined; + const completed: number[] = []; + try { + const code = await runSdpCli( + ["validate", root, "--watch", "--rules", file, "--params", '{"x":1}'], + capture.output, + { + abortSignal: abort.signal, + createWatchSource: () => ({ + on: (callback) => { + listener = callback; + }, + onError: () => { + /* This event source never fails. */ + }, + close: () => { + /* No native watch handle is allocated. */ + }, + }), + onWatchCycleComplete: (cycle) => { + completed.push(cycle.exitCode); + if (cycle.index === 1) listener?.({ type: "change", path: "first.sdp.md" }); + else abort.abort(); + }, + }, + ); + expect(code).toBe(0); + expect(completed).toEqual([0, 0]); + } finally { + abort.abort(); + } +}); diff --git a/test/entry-locations.test.ts b/test/entry-locations.test.ts index 9be411c2..c8bb63f2 100644 --- a/test/entry-locations.test.ts +++ b/test/entry-locations.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { ref, specTest, testAnchorId } from "@libar-dev/software-delivery-protocol"; -import { extract, reifyMarkdownCarrier, serializeGraph } from "../src/index.js"; +import { extract, reifyMarkdownCarrier, serializeGraph, validateGraph } from "../src/index.js"; import type { GraphSchema } from "../src/graph/schema.js"; import { deriveGraph } from "../src/extract/derive.js"; import { reifyGherkinCarrier } from "../src/extract/gherkin.js"; @@ -13,7 +13,7 @@ import { serializeGraphStructure } from "./helpers/graph-structure.js"; const entryLocationsTestAnchor = specTest({ id: testAnchorId("test:protocol.entry-locations"), - verifies: ref("spec:extraction.entry-locations"), + verifies: [ref("spec:extraction.entry-locations"), ref("spec:validation.prose-mentions")], }); void entryLocationsTestAnchor; @@ -388,3 +388,111 @@ relations: expect(serializeGraph(extract({ root }).graph)).toBe(serializeGraph(extract({ root }).graph)); }); }); + +describe("prose mention finding lines", () => { + it.each([ + ["behavior", "Behavior", "rule: ", "rules", "rule"], + ["behavior", "Behavior", "flow: ", "flows", "flow"], + ["rule", "Rule", "", "rules", "rule"], + ["workflow", "Workflow", "", "flows", "flow"], + ["workflow", "Workflow", "rule: ", "rules", "rule"], + ["contract", "Contract", "", "rules", "rule"], + ])("locates errors and warnings in %s %s %s entries", (kind, heading, prefix, list, entry) => { + const source = `--- +id: spec:probe.mentions +kind: ${kind} +altitude: story +readiness: idea +relations: {} +--- +# Mention locations + +## Intent +- outcome: Locate every finding in the primary behavior owner. + +## ${heading} +- ${prefix}Read spec:probe.target here. +- ${prefix}Read spec:missing.target here. +- ${prefix}Read spec:probe.target#design.absent here. +- ${prefix}Read spec:bad_token here. +`; + const root = corpus("mentions.sdp.md", source); + writeFileSync( + join(root, "target.sdp.md"), + `--- +id: spec:probe.target +kind: behavior +altitude: story +readiness: idea +relations: {} +--- +# Target + +## Intent +- outcome: Be mentioned. +`, + ); + const result = extract({ root }); + expect(result.report.findings).toEqual([]); + expect(result.graph.locations?.filter((row) => row.spec === "spec:probe.mentions")).toEqual( + [14, 15, 16, 17].map((line, index) => ({ + spec: "spec:probe.mentions", + file: "mentions.sdp.md", + entry: `${entry}[${String(index)}]`, + line, + })), + ); + const findings = validateGraph(result.graph).findings.filter( + (finding) => finding.validatorId === "conformance/prose-mentions", + ); + expect( + findings + .map(({ severity, path, line }) => ({ severity, path, line })) + .sort((a, b) => (a.path ?? "").localeCompare(b.path ?? "")), + ).toEqual([ + { severity: "warning", path: `behavior.${list}[0]`, line: 14 }, + { severity: "error", path: `behavior.${list}[1]`, line: 15 }, + { severity: "error", path: `behavior.${list}[2]`, line: 16 }, + { severity: "error", path: `behavior.${list}[3]`, line: 17 }, + ]); + }); + + it("locates question text, fields and links and omits an unrecorded line", () => { + const root = corpus( + "mentions.sdp.md", + `--- +id: spec:probe.mentions +kind: behavior +altitude: story +readiness: idea +relations: {} +--- +# Mention locations + +Read spec:missing.narrative here. + +## Intent +- outcome: Locate question text and fields. + +### Open questions +- [non-blocking #choice] Read spec:missing.question here? + - links: spec:missing.link + - provisional: Read spec:missing.provisional here. +- [blocking #later] Which design? + - trigger: Read spec:missing.trigger here. +`, + ); + const result = extract({ root }); + expect(result.report.findings).toEqual([]); + const findings = validateGraph(result.graph).findings.filter( + (finding) => finding.validatorId === "conformance/prose-mentions", + ); + expect(findings.map(({ path, line }) => ({ path, line }))).toEqual([ + { path: "narrative", line: undefined }, + { path: "intent.openQuestions[0].question", line: 16 }, + { path: "intent.openQuestions[0].links[0]", line: 17 }, + { path: "intent.openQuestions[0].provisional", line: 18 }, + { path: "intent.openQuestions[1].trigger", line: 20 }, + ]); + }); +}); diff --git a/test/iteration-recipes.test.ts b/test/iteration-recipes.test.ts index 7a9f6989..782847ce 100644 --- a/test/iteration-recipes.test.ts +++ b/test/iteration-recipes.test.ts @@ -788,15 +788,18 @@ interface Perspective { interface Bundle { scope: { pack?: string; subset?: boolean; members?: string[]; specs?: string[] }; budget: number; + declarations: "whole" | "addresses"; used: number; tokenEstimate: { value: number; method: string }; leftOut: string[]; pack: { id: string; framing: string } | null; members: { id: string; + file: string; narrative: string | null; sections: Record; questions: { + index: number; question: string; blocking: boolean; address: string | null; @@ -804,8 +807,8 @@ interface Bundle { decider?: string; trigger?: string; }[]; - declarations: { spec: string; key: string; declaration: string }[]; - examples: { id: string; steps: unknown; enabled: boolean }[]; + declarations: string[]; + examples: { id: string; member: boolean; file: string; steps: unknown; enabled: boolean }[]; footing: { type: string; id: string; claim: string; reason?: string }[]; }[]; boundary: Record; @@ -813,6 +816,7 @@ interface Bundle { interface BudgetExceeded { scope: Bundle["scope"]; budget: number; + declarations: "whole" | "addresses"; budgetExceeded: true; protectedLength: number; leftOut: string[]; @@ -1089,7 +1093,166 @@ function prepareBundleCorpus(root: string): void { ); } +function asSection(value: unknown): Record { + return value as Record; +} + describe("iteration loop: context bundle", () => { + it("keeps questions and declarations in one place and member-example steps only in their member row", async () => { + await withCorpus(async (root) => { + prepareBundleCorpus(root); + edit( + root, + "review.pack.sdp.md", + " - spec:probe.model", + " - spec:probe.model\n - spec:probe.example", + ); + const derived = extract({ root }); + const bundle = await runRecipe(36, { pack: "pack:probe.review" }, derived); + expect(bundle.declarations).toBe("whole"); + const parent = bundle.members[0]; + expect(parent?.file).toBe("a.sdp.md"); + expect(parent?.declarations).toEqual(["alpha"]); + expect(asSection(parent?.sections.design).alpha).toBe("`Alpha` declaration"); + const questioning = bundle.members[1]; + expect(asSection(questioning?.sections.intent)).not.toHaveProperty("openQuestions"); + expect(questioning?.questions.map((question) => question.index)).toEqual([0, 1]); + expect(questioning?.questions[0]).not.toHaveProperty("file"); + expect(questioning?.questions[0]?.address).toBe("spec:probe.b#question.hold"); + expect(typeof questioning?.questions[0]?.line).toBe("number"); + expect(parent?.examples).toEqual([ + expect.objectContaining({ + id: "spec:probe.example", + member: true, + file: "example.sdp.md", + steps: null, + }), + ]); + expect(JSON.stringify(bundle.members[3]?.sections)).toContain("Step text secret"); + const subset = await runRecipe(36, { specs: ["spec:probe.a"] }, derived); + expect(subset.members[0]?.examples[0]?.member).toBe(false); + expect(JSON.stringify(subset.members[0]?.examples[0]?.steps)).toContain("Step text secret"); + }); + }); + + it("takes the declaration cut last only with addresses and keeps compact pointers and links", async () => { + await withCorpus(async (root) => { + prepareBundleCorpus(root); + const long = "Declaration detail ".repeat(100); + edit(root, "a.sdp.md", "`Alpha` declaration", `\`Alpha\` declaration ${long}`); + edit( + root, + "a.sdp.md", + "- outcome: Read a in context.", + "- outcome: Read a in context. spec:probe.a#rule.first", + ); + edit( + root, + "b.sdp.md", + "\n\n## Behavior", + "\n - links: spec:probe.a#rule.last\n\n## Behavior", + ); + const derived = extract({ root }); + const scope = { pack: "pack:probe.review", declarations: "addresses" }; + let prior = await runRecipe(36, scope, derived); + const lines = [ + "Boundary details; ids kept per direction.", + "Footing details; ids kept per relation type.", + "Member narrative.", + "Example step text, including member sections and example-space vocabulary.", + "Non-blocking question text; addresses and links kept.", + "Section entry values other than pinned declarations; addresses and lines kept.", + "Pinned declaration text; entry addresses kept.", + ]; + for (let cut = 1; cut <= 7; cut += 1) { + const budget = prior.used - 20; + const result = await runRecipe(36, { ...scope, budget }, derived); + expect(result.leftOut).toEqual(lines.slice(0, cut)); + expect(result.used).toBe(JSON.stringify(result).length); + expect(result.used).toBeLessThanOrEqual(budget); + expect(result.declarations).toBe("addresses"); + if (cut === 2) + expect(result.members[0]?.footing).toEqual({ dependsOn: ["spec:probe.outside"] }); + if (cut === 6) { + const sections = result.members[0]?.sections; + expect(asSection(sections?.intent).outcome).toEqual([ + "spec:probe.a#rule.first", + { line: null }, + ]); + expect(asSection(sections?.design).beta).toBe("spec:probe.a#design.beta"); + const ruleLine = derived.graph.locations?.find( + (at) => at.spec === "spec:probe.a" && at.entry === "rule[1]", + )?.line; + expect(typeof ruleLine).toBe("number"); + expect(asSection(sections?.behavior).rules).toEqual([ + "spec:probe.a#rule.first", + { line: ruleLine }, + ]); + expect(result.members[1]?.questions[1]).toEqual({ + pointer: "spec:probe.b#question.later", + links: ["spec:probe.a#rule.last"], + }); + expect(JSON.stringify(result)).not.toContain('"path":'); + expect(JSON.stringify(result)).not.toContain('"spec":"spec:probe.a"'); + } + if (cut === 7) { + expect(asSection(result.members[0]?.sections.design).alpha).toBe( + "spec:probe.a#design.alpha", + ); + expect(result.members[0]?.declarations).toEqual(["alpha"]); + const whole = await runRecipe(36, { pack: scope.pack, budget }, derived); + expect(whole).toMatchObject({ + budgetExceeded: true, + declarations: "whole", + leftOut: lines.slice(0, 6), + }); + expect(whole).not.toHaveProperty("members"); + } + expect(await runRecipe(36, { ...scope, budget }, derived)).toEqual(result); + prior = result; + } + const exceeded = await runRecipe(36, { ...scope, budget: 0 }, derived); + expect(exceeded).toMatchObject({ + declarations: "addresses", + budgetExceeded: true, + leftOut: lines, + }); + }); + }, 20_000); + + it("cuts a pinned dotted key to its line when it has no entry address", async () => { + await withCorpus(async (root) => { + writeFileSync( + join(root, "dotted.sdp.ts"), + `import { spec, specId } from "@libar-dev/software-delivery-protocol"; +export const dotted = spec({ + id: specId("spec:probe.dotted"), title: "Dotted", kind: "behavior", altitude: "story", readiness: "idea", + intent: { outcome: "Keep pinned detail" }, + design: { "a.b": "\`Thing\` ${"required detail ".repeat(100)}" }, +});`, + ); + const derived = extract({ root }); + const full = await runRecipe(36, { specs: ["spec:probe.dotted"] }, derived); + const whole = await runRecipe( + 36, + { specs: ["spec:probe.dotted"], budget: 1 }, + derived, + ); + const bundle = await runRecipe( + 36, + { + specs: ["spec:probe.dotted"], + budget: Math.min(full.used, whole.protectedLength) - 20, + declarations: "addresses", + }, + derived, + ); + expect(bundle.leftOut).toHaveLength(7); + expect(bundle.members[0]?.declarations).toEqual(["a.b"]); + expect(asSection(bundle.members[0]?.sections.design)["a.b"]).toEqual({ line: 5 }); + }); + }); + it("composes both scopes, dependency footing, declarations, example bindings and a perspective subset", async () => { await withCorpus(async (root) => { prepareBundleCorpus(root); @@ -1109,9 +1272,7 @@ describe("iteration loop: context bundle", () => { expect(result.members[0]?.footing).toEqual([ expect.objectContaining({ id: "spec:probe.outside", type: "dependsOn", claim: "declared" }), ]); - expect(result.members[0]?.declarations).toEqual([ - { spec: "spec:probe.a", key: "alpha", declaration: "Alpha" }, - ]); + expect(result.members[0]?.declarations).toEqual(["alpha"]); expect(result.members[0]?.examples).toEqual([ expect.objectContaining({ id: "spec:probe.example", enabled: true }), ]); @@ -1162,7 +1323,7 @@ describe("iteration loop: context bundle", () => { }); }, 20_000); - it("takes all five cuts in fixed order and counts the complete returned value including Unicode", async () => { + it("takes all six whole-mode cuts in fixed order and counts the complete returned value including Unicode", async () => { await withCorpus(async (root) => { prepareBundleCorpus(root); edit(root, "b.sdp.md", "# b", "# b 🐈"); @@ -1175,7 +1336,7 @@ describe("iteration loop: context bundle", () => { const full = prior; expect(Buffer.byteLength(JSON.stringify(full), "utf8")).toBeGreaterThan(full.used); const results: Bundle[] = []; - for (let cut = 1; cut <= 5; cut += 1) { + for (let cut = 1; cut <= 6; cut += 1) { const budget = prior.used - 20; const result = await runRecipe(36, { pack: "pack:probe.review", budget }, derived); expect(result.leftOut).toHaveLength(cut); @@ -1189,16 +1350,17 @@ describe("iteration loop: context bundle", () => { prior = result; } expect(results[0]?.boundary.restsOn).toEqual(["spec:probe.outside"]); - expect(results[1]?.members[0]?.narrative).toBeNull(); - expect(JSON.stringify(results[2])).not.toContain("Step text secret"); - expect(JSON.stringify(results[3])).not.toContain("Later question?"); - expect(JSON.stringify(results[3])).toContain("spec:probe.b#question.later"); - expect(JSON.stringify(results[4]?.members[0]?.sections)).not.toContain("Beta text"); - expect(JSON.stringify(results[4]?.members[0]?.sections)).toContain( + expect(results[1]?.members[0]?.footing).toEqual({ dependsOn: ["spec:probe.outside"] }); + expect(results[2]?.members[0]?.narrative).toBeNull(); + expect(JSON.stringify(results[3])).not.toContain("Step text secret"); + expect(JSON.stringify(results[4])).not.toContain("Later question?"); + expect(JSON.stringify(results[4])).toContain("spec:probe.b#question.later"); + expect(JSON.stringify(results[5]?.members[0]?.sections)).not.toContain("Beta text"); + expect(JSON.stringify(results[5]?.members[0]?.sections)).toContain( "spec:probe.a#design.beta", ); - expect(JSON.stringify(results[4]?.members[0]?.sections)).toContain("`Alpha` declaration"); - expect(JSON.stringify(results[4]?.members[1]?.sections)).toContain( + expect(JSON.stringify(results[5]?.members[0]?.sections)).toContain("`Alpha` declaration"); + expect(JSON.stringify(results[5]?.members[1]?.questions)).toContain( "Protected blocking question?", ); const equal = await runRecipe( @@ -1217,6 +1379,7 @@ describe("iteration loop: context bundle", () => { expect(exceeded).toEqual({ scope: full.scope, budget: 1, + declarations: "whole", budgetExceeded: true, protectedLength: exceeded.protectedLength, leftOut: prior.leftOut, @@ -1232,7 +1395,7 @@ describe("iteration loop: context bundle", () => { }); }, 20_000); - it("keeps a pinned declaration whole through the fifth cut when its key contains a dot", async () => { + it("keeps a pinned declaration whole through the sixth cut when its key contains a dot", async () => { await withCorpus(async (root) => { const long = "Reading material ".repeat(100); writeFileSync( @@ -1248,9 +1411,10 @@ export const dotted = spec({ expect(derived.report.findings.filter((finding) => finding.severity === "error")).toEqual([]); const specs = ["spec:probe.dotted"]; let result = await runRecipe(36, { specs, budget: 100000 }, derived); - while (result.leftOut.length < 5) { + for (let attempt = 0; result.leftOut.length < 6 && attempt < 6; attempt += 1) { result = await runRecipe(36, { specs, budget: result.used - 1 }, derived); } + expect(result.leftOut).toHaveLength(6); expect(result.members[0]?.sections.design).toMatchObject({ "a.b": "`Thing` required detail", ab: "`Thing` required detail", @@ -1273,6 +1437,9 @@ export const dotted = spec({ { specs: [], budget: -1 }, { specs: [], budget: 0.5 }, { specs: [], budget: "100" }, + { specs: [], declarations: "all" }, + { specs: [], declarations: true }, + { specs: [], declarations: null }, ]) { const result = await runRecipe<{ diagnostics: unknown[] }>(36, params, derived); expect(Object.keys(result)).toEqual(["diagnostics"]); @@ -1737,24 +1904,26 @@ describe("iteration loop: comparison and budget edges", () => { }); }, 20_000); - it("cuts example text in both member sections and related examples and preserves locations after the fifth cut", async () => { + it("cuts example text once and preserves compact pointers after the sixth cut", async () => { await withCorpus(async (root) => { prepareBundleCorpus(root); const derived = extract({ root }); const specs = ["spec:probe.a", "spec:probe.b", "spec:probe.example"]; let result = await runRecipe(36, { specs, budget: 100000 }, derived); - expect(JSON.stringify(result.members[0]?.examples)).toContain("Step text secret"); + expect(result.members[0]?.examples[0]?.steps).toBeNull(); expect(JSON.stringify(result.members[2]?.sections)).toContain("Step text secret"); - for (let cut = 1; cut <= 5; cut += 1) { + for (let cut = 1; cut <= 6; cut += 1) { result = await runRecipe(36, { specs, budget: result.used - 20 }, derived); expect(result.leftOut).toHaveLength(cut); - if (cut >= 3) expect(JSON.stringify(result)).not.toContain("Step text secret"); - if (cut >= 4) expect(JSON.stringify(result)).not.toContain("Later question?"); + if (cut >= 4) expect(JSON.stringify(result)).not.toContain("Step text secret"); + if (cut >= 5) expect(JSON.stringify(result)).not.toContain("Later question?"); } expect(JSON.stringify(result.members[0]?.sections)).toContain("spec:probe.a#rule.first"); expect(JSON.stringify(result.members[0]?.sections)).toContain('"line":null'); - expect(JSON.stringify(result.members[0]?.sections)).toContain('"path":"intent.outcome"'); - expect(JSON.stringify(result.members[1]?.sections)).toContain("Protected blocking question?"); + expect(asSection(result.members[0]?.sections.intent).outcome).toEqual({ line: null }); + expect(JSON.stringify(result.members[1]?.questions)).toContain( + "Protected blocking question?", + ); expect(result.used).toBe(JSON.stringify(result).length); }); }, 20_000); @@ -1887,23 +2056,28 @@ describe("iteration loop: protected entry addresses", () => { it("keeps addresses in each place when narrative, example, question and section text are cut", async () => { await withCorpus(async (root) => { prepareBundleCorpus(root); - edit(root, "a.sdp.md", "Narrative for a.", "Narrative for a. spec:probe.a#rule.first"); - edit(root, "a.sdp.md", "Beta text", "Beta text spec:probe.a#ui.panel"); - edit(root, "b.sdp.md", "Later question?", "Later question? spec:probe.a#design.beta"); + edit( + root, + "a.sdp.md", + "Narrative for a.", + "Narrative for a. spec:probe.a#rule.first. A tail follows.", + ); + edit(root, "a.sdp.md", "Beta text", "Beta text spec:probe.a#ui.panel."); + edit(root, "b.sdp.md", "Later question?", "Later question? spec:probe.a#design.beta."); edit( root, "b.sdp.md", "\n\n## Behavior", "\n - links: spec:probe.a#rule.last\n\n## Behavior", ); - edit(root, "example.sdp.md", "Step text secret", "Step text secret spec:probe.a#rule.first"); + edit(root, "example.sdp.md", "Step text secret", "Step text secret spec:probe.a#rule.first."); const derived = extract({ root }); let result = await runRecipe( 36, { specs: ["spec:probe.a", "spec:probe.b", "spec:probe.example"], budget: 100000 }, derived, ); - for (let cut = 1; cut <= 5; cut += 1) { + for (let cut = 1; cut <= 6; cut += 1) { result = await runRecipe( 36, { @@ -1915,12 +2089,12 @@ describe("iteration loop: protected entry addresses", () => { expect(result.leftOut).toHaveLength(cut); } expect(result.members[0]?.narrative).toBe("spec:probe.a#rule.first"); - expect(JSON.stringify(result.members[0]?.examples)).toContain("spec:probe.a#rule.first"); + expect(result.members[0]?.examples[0]?.steps).toBeNull(); expect(JSON.stringify(result.members[2]?.sections)).toContain("spec:probe.a#rule.first"); expect(JSON.stringify(result.members[1]?.questions)).toContain("spec:probe.b#question.later"); expect(JSON.stringify(result.members[1]?.questions)).toContain("spec:probe.a#design.beta"); expect(JSON.stringify(result.members[1]?.questions)).toContain("spec:probe.a#rule.last"); - expect(JSON.stringify(result.members[1]?.sections)).toContain("spec:probe.b#question.later"); + expect(asSection(result.members[1]?.sections.intent)).not.toHaveProperty("openQuestions"); expect(JSON.stringify(result.members[0]?.sections)).toContain("spec:probe.a#design.beta"); expect(JSON.stringify(result.members[0]?.sections)).toContain("spec:probe.a#ui.panel"); expect(result.used).toBe(JSON.stringify(result).length); diff --git a/test/model.planned-architecture.differing-layer-and-uses.test.generated.ts b/test/model.planned-architecture.differing-layer-and-uses.test.generated.ts new file mode 100644 index 00000000..b6f1e37b --- /dev/null +++ b/test/model.planned-architecture.differing-layer-and-uses.test.generated.ts @@ -0,0 +1,83 @@ +// GENERATED by `sdp build` — the runnable registrar of spec:model.planned-architecture.differing-layer-and-uses. +// Derived from the extracted graph (the statically-reified truth, MD-14), keyed by spec ID: +// regenerable, never edited, importable because it is a projection (JS-B2.6's pattern) — +// never the authored spec module (JS-B2.3: no spec↔code import edges). + +import type { StepBindings } from "@libar-dev/software-delivery-protocol/runner"; +import { + compareContractOutcome, + invokeRunnableExample, + registerRunnableExample, +} from "@libar-dev/software-delivery-protocol/testing"; +import type { + RunnableExampleAdapters, + RunnableExampleWorld, +} from "@libar-dev/software-delivery-protocol/testing"; +import { differingLayerAndUsesContract } from "../generated/contracts/model.planned-architecture.differing-layer-and-uses.contract.js"; +import type { + Step, + StepParams, +} from "../generated/contracts/model.planned-architecture.differing-layer-and-uses.contract.js"; +import type { + PlannedArchitectureConditions, + PlannedArchitectureOutcome, +} from "../generated/contracts/model.planned-architecture.space.js"; + +export function registerDifferingLayerAndUses( + adapters: RunnableExampleAdapters< + World, + PlannedArchitectureConditions, + PlannedArchitectureOutcome + >, +): void { + type Execution = RunnableExampleWorld< + World, + PlannedArchitectureConditions, + PlannedArchitectureOutcome + >; + const bindings = { + "a component design for {componentId} that plans the layer {plannedLayer} and uses {plannedUses}": + () => { + // Given state is materialized by createWorld from the generated point. + }, + "the component anchor for that design {anchor}": () => { + // Given state is materialized by createWorld from the generated point. + }, + "that anchor states the layer {realizedLayer} and its member uses {realizedUses}": () => { + // Given state is materialized by createWorld from the generated point. + }, + "the realization comparison runs": async (execution) => { + await invokeRunnableExample(execution); + }, + "the comparison lists {unrealized} unrealized designs, {matching} matching designs and {differing} differing rows": + (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the comparison lists {unrealized} unrealized designs, {matching} matching designs and {differing} differing rows", + params, + }); + }, + "the differing fields are {fields}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the differing fields are {fields}", + params, + }); + }, + } satisfies StepBindings; + + registerRunnableExample( + differingLayerAndUsesContract, + { + componentId: "component:probe.history", + plannedLayer: "domain", + plannedUses: "component:probe.kernel", + anchor: "satisfies the design", + realizedLayer: "application", + realizedUses: "component:probe.audit", + }, + ["componentId", "plannedLayer", "plannedUses", "anchor", "realizedLayer", "realizedUses"], + adapters, + bindings, + ); +} diff --git a/test/model.planned-architecture.matching-design.test.generated.ts b/test/model.planned-architecture.matching-design.test.generated.ts new file mode 100644 index 00000000..1b711815 --- /dev/null +++ b/test/model.planned-architecture.matching-design.test.generated.ts @@ -0,0 +1,83 @@ +// GENERATED by `sdp build` — the runnable registrar of spec:model.planned-architecture.matching-design. +// Derived from the extracted graph (the statically-reified truth, MD-14), keyed by spec ID: +// regenerable, never edited, importable because it is a projection (JS-B2.6's pattern) — +// never the authored spec module (JS-B2.3: no spec↔code import edges). + +import type { StepBindings } from "@libar-dev/software-delivery-protocol/runner"; +import { + compareContractOutcome, + invokeRunnableExample, + registerRunnableExample, +} from "@libar-dev/software-delivery-protocol/testing"; +import type { + RunnableExampleAdapters, + RunnableExampleWorld, +} from "@libar-dev/software-delivery-protocol/testing"; +import { matchingDesignContract } from "../generated/contracts/model.planned-architecture.matching-design.contract.js"; +import type { + Step, + StepParams, +} from "../generated/contracts/model.planned-architecture.matching-design.contract.js"; +import type { + PlannedArchitectureConditions, + PlannedArchitectureOutcome, +} from "../generated/contracts/model.planned-architecture.space.js"; + +export function registerMatchingDesign( + adapters: RunnableExampleAdapters< + World, + PlannedArchitectureConditions, + PlannedArchitectureOutcome + >, +): void { + type Execution = RunnableExampleWorld< + World, + PlannedArchitectureConditions, + PlannedArchitectureOutcome + >; + const bindings = { + "a component design for {componentId} that plans the layer {plannedLayer} and uses {plannedUses}": + () => { + // Given state is materialized by createWorld from the generated point. + }, + "the component anchor for that design {anchor}": () => { + // Given state is materialized by createWorld from the generated point. + }, + "that anchor states the layer {realizedLayer} and its member uses {realizedUses}": () => { + // Given state is materialized by createWorld from the generated point. + }, + "the realization comparison runs": async (execution) => { + await invokeRunnableExample(execution); + }, + "the comparison lists {unrealized} unrealized designs, {matching} matching designs and {differing} differing rows": + (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the comparison lists {unrealized} unrealized designs, {matching} matching designs and {differing} differing rows", + params, + }); + }, + "the differing fields are {fields}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the differing fields are {fields}", + params, + }); + }, + } satisfies StepBindings; + + registerRunnableExample( + matchingDesignContract, + { + componentId: "component:probe.history", + plannedLayer: "domain", + plannedUses: "component:probe.kernel", + anchor: "satisfies the design", + realizedLayer: "domain", + realizedUses: "component:probe.kernel", + }, + ["componentId", "plannedLayer", "plannedUses", "anchor", "realizedLayer", "realizedUses"], + adapters, + bindings, + ); +} diff --git a/test/model.planned-architecture.unrealized-design.test.generated.ts b/test/model.planned-architecture.unrealized-design.test.generated.ts new file mode 100644 index 00000000..dbd45fbb --- /dev/null +++ b/test/model.planned-architecture.unrealized-design.test.generated.ts @@ -0,0 +1,83 @@ +// GENERATED by `sdp build` — the runnable registrar of spec:model.planned-architecture.unrealized-design. +// Derived from the extracted graph (the statically-reified truth, MD-14), keyed by spec ID: +// regenerable, never edited, importable because it is a projection (JS-B2.6's pattern) — +// never the authored spec module (JS-B2.3: no spec↔code import edges). + +import type { StepBindings } from "@libar-dev/software-delivery-protocol/runner"; +import { + compareContractOutcome, + invokeRunnableExample, + registerRunnableExample, +} from "@libar-dev/software-delivery-protocol/testing"; +import type { + RunnableExampleAdapters, + RunnableExampleWorld, +} from "@libar-dev/software-delivery-protocol/testing"; +import { unrealizedDesignContract } from "../generated/contracts/model.planned-architecture.unrealized-design.contract.js"; +import type { + Step, + StepParams, +} from "../generated/contracts/model.planned-architecture.unrealized-design.contract.js"; +import type { + PlannedArchitectureConditions, + PlannedArchitectureOutcome, +} from "../generated/contracts/model.planned-architecture.space.js"; + +export function registerUnrealizedDesign( + adapters: RunnableExampleAdapters< + World, + PlannedArchitectureConditions, + PlannedArchitectureOutcome + >, +): void { + type Execution = RunnableExampleWorld< + World, + PlannedArchitectureConditions, + PlannedArchitectureOutcome + >; + const bindings = { + "a component design for {componentId} that plans the layer {plannedLayer} and uses {plannedUses}": + () => { + // Given state is materialized by createWorld from the generated point. + }, + "the component anchor for that design {anchor}": () => { + // Given state is materialized by createWorld from the generated point. + }, + "that anchor states the layer {realizedLayer} and its member uses {realizedUses}": () => { + // Given state is materialized by createWorld from the generated point. + }, + "the realization comparison runs": async (execution) => { + await invokeRunnableExample(execution); + }, + "the comparison lists {unrealized} unrealized designs, {matching} matching designs and {differing} differing rows": + (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the comparison lists {unrealized} unrealized designs, {matching} matching designs and {differing} differing rows", + params, + }); + }, + "the differing fields are {fields}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the differing fields are {fields}", + params, + }); + }, + } satisfies StepBindings; + + registerRunnableExample( + unrealizedDesignContract, + { + componentId: "component:probe.history", + plannedLayer: "domain", + plannedUses: "component:probe.kernel", + anchor: "is absent", + realizedLayer: "absent", + realizedUses: "", + }, + ["componentId", "plannedLayer", "plannedUses", "anchor", "realizedLayer", "realizedUses"], + adapters, + bindings, + ); +} diff --git a/test/package-smoke.test.ts b/test/package-smoke.test.ts index 59c23230..1a6ac888 100644 --- a/test/package-smoke.test.ts +++ b/test/package-smoke.test.ts @@ -74,6 +74,8 @@ const expectedRootExports = [ "isResolvingTestAnchorVerify", "kindEvidence", "machineToken", + "matchConcept", + "mentionTokens", "MarkdownEmissionError", "mermaidToken", "oracleAnchorId", @@ -109,6 +111,7 @@ const expectedRootExports = [ ] as const; const typeOnlyRootExports = [ + "ConceptRecord", "CarrierReification", "CarrierReifier", "ImportResult", @@ -184,11 +187,18 @@ describe("published package surface", () => { const consumer = join(packageRoot, "consumer"); try { - run("npm", ["run", "build"], repositoryRoot); - const dryRun = JSON.parse( - run("npm", ["pack", "--dry-run", "--json"], repositoryRoot), - ) as readonly [{ readonly files: readonly { readonly path: string }[] }]; - const dryRunPaths = dryRun[0].files.map((file) => file.path); + // npm ci or the check gate has built dist/. Reuse that build here; the separate git-install + // gate proves prepare from a clean commit without rebuilding inside the pooled suite. + const packed = JSON.parse( + run( + "npm", + ["pack", "--ignore-scripts", "--json", "--pack-destination", packageRoot], + repositoryRoot, + ), + ) as readonly [ + { readonly filename: string; readonly files: readonly { readonly path: string }[] }, + ]; + const packedPaths = packed[0].files.map((file) => file.path); const adopterAssets = [ "CONTEXT.md", ".agents/skills/sdp-agent-surface/SKILL.md", @@ -204,9 +214,9 @@ describe("published package surface", () => { "docs/concept/DECISIONS.md", ]; - expect(dryRunPaths).toEqual(expect.arrayContaining(packedAssets)); + expect(packedPaths).toEqual(expect.arrayContaining(packedAssets)); expect( - dryRunPaths.filter( + packedPaths.filter( (path) => !["LICENSE", "README.md", "package.json", ...packedAssets].includes(path) && !path.startsWith("dist/") && @@ -227,21 +237,14 @@ describe("published package surface", () => { } }; await walkCarriers("specs"); - expect(dryRunPaths.filter((path) => path.startsWith("specs/")).sort()).toEqual( + expect(packedPaths.filter((path) => path.startsWith("specs/")).sort()).toEqual( carrierPaths.sort(), ); expect( - dryRunPaths.filter((path) => !path.startsWith("dist/") && /\.tsx?$/u.test(path)), + packedPaths.filter((path) => !path.startsWith("dist/") && /\.tsx?$/u.test(path)), ).toEqual([]); - run("npm", ["pack", "--pack-destination", packageRoot], repositoryRoot); - - const tarballs = await readdir(packageRoot); - const tarball = tarballs.find((entry) => entry.endsWith(".tgz")); - - if (tarball === undefined) { - throw new Error("npm pack did not create a tarball."); - } + const tarball = packed[0].filename; await mkdir(consumer); await writeFile( @@ -262,10 +265,38 @@ describe("published package surface", () => { repositoryRoot, ); await writeFile(join(consumer, "consumer.mjs"), consumerDriver()); + await writeFile( + join(consumer, "node-free-loader.mjs"), + `export async function resolve(specifier, context, nextResolve) { + if (specifier.startsWith("node:")) throw new Error("Unexpected Node dependency: " + specifier); + return nextResolve(specifier, context); +} +`, + ); + expect( + run( + process.execPath, + [ + "--no-warnings", + "--loader", + "./node-free-loader.mjs", + "--input-type=module", + "-e", + `import { matchConcept } from "${packageName}/concept-match"; +if (matchConcept([{ id: "spec:smoke", nodeType: "Primitive", title: "needle" }], "needle")[0]?.id !== "spec:smoke") throw new Error("Matcher not callable"); +console.log("node-free matcher available");`, + ], + consumer, + ), + ).toBe("node-free matcher available\n"); await writeFile( join(consumer, "type-surface.mts"), `import { ${expectedRootExports.join(", ")} } from "${packageName}"; import type { ${typeOnlyRootExports.join(", ")} } from "${packageName}"; +import { matchConcept as standaloneMatcher } from "${packageName}/concept-match"; +import type { ConceptRecord as StandaloneConceptRecord } from "${packageName}/concept-match"; +const record: StandaloneConceptRecord = { id: "spec:smoke", nodeType: "Primitive" }; +standaloneMatcher([record], "smoke"); void [${expectedRootExports.join(", ")}]; `, ); diff --git a/test/recipes.test.ts b/test/recipes.test.ts index ff6b0e02..054c083a 100644 --- a/test/recipes.test.ts +++ b/test/recipes.test.ts @@ -457,7 +457,9 @@ describe("the agent-surface recipe corpus", () => { // A parameter is data on `--params`, never source spliced into a body, and no recipe reads // the environment. The intro teaches exactly that and names no retired channel. - expect(introProse).toContain("Pass it as JSON data with `--params`, never by editing the body"); + expect(introProse).toContain( + "Pass parameters as JSON data with `--params`, never by editing the body", + ); expect(source).not.toContain("SDP_CHANGED_FILES_JSON"); for (const recipe of recipes) { expect({ recipe: recipe.ordinal, env: recipe.body.includes("process.env") }).toEqual({ @@ -2049,6 +2051,133 @@ export const probe = spec({ } describe("register recipe semantics", () => { + it("lists key families across the corpus in key, Spec and section order with the documented sample", async () => { + const result = asRecord(await runRecipe(recipeByOrdinal(41))); + expect(result.prefixes).toEqual(["finding", "impact"]); + expect(result.scope).toEqual({ corpus: true }); + const rows = asArray(result.rows).map(asRecord); + expect(rows.length).toBeGreaterThan(0); + const tuples = rows.map( + (row) => `${String(row.key)}\0${String(row.spec)}\0${String(row.section)}`, + ); + expect(tuples).toEqual([...tuples].sort()); + const findingRows = rows.filter((row) => String(row.key).startsWith("finding")); + const impactRows = rows.filter((row) => String(row.key).startsWith("impact")); + expect(rows.slice(0, 2).map((row) => ({ key: row.key, spec: row.spec }))).toEqual([ + { key: "findingIds", spec: "spec:carrier.sdp-import" }, + { key: "findingIds", spec: "spec:extraction.executable-contracts" }, + ]); + expect(result.totals).toEqual({ + rows: rows.length, + specs: new Set(rows.map((row) => row.spec)).size, + byPrefix: { finding: findingRows.length, impact: impactRows.length }, + }); + }); + + it("lists Design and UI key families without a row cap, preserving values, lines and overlapping prefixes", async () => { + const extraction = markdownProbe("sdp-key-family-", { + "a.sdp.md": `---\nid: spec:probe.a\nkind: rule\naltitude: story\nreadiness: idea\nrelations: {}\n---\n# A\n\n## Intent\n- outcome: List keyed values.\n### Open questions\n- [non-blocking #limitQuestion] Who holds the limit?\n\n## Rule\n- [#limitRule] Hold the limit.\n\n## Design\nLimit description.\n${Array.from({ length: 30 }, (_, index) => `- limit${String(index).padStart(2, "0")}: Value ${String(index)}`).join("\n")}\n\n## UI\n- limit00: Visible limit\n`, + "b.sdp.md": + "---\nid: spec:probe.b\nkind: rule\naltitude: story\nreadiness: idea\nrelations: {}\n---\n# B\n## Intent\n- outcome: List keyed values.\n## Design\n- limit00: Another value\n- otherLimit: Case-sensitive value\n", + "p.pack.sdp.md": "---\nid: pack:probe.family\nspecs: [spec:probe.b]\n---\n# Family\n", + }); + const query = (params: Record) => + runRecipe(recipeByOrdinal(41), params, extraction); + const result = asRecord(await query({ prefixes: ["limit", "limit0", "description"] })); + const rows = asArray(result.rows).map(asRecord); + expect(rows).toHaveLength(32); + expect(rows.slice(0, 3)).toEqual([ + { + key: "limit00", + spec: "spec:probe.a", + section: "design", + value: "Value 0", + file: "a.sdp.md", + line: 20, + }, + { + key: "limit00", + spec: "spec:probe.a", + section: "ui", + value: "Visible limit", + file: "a.sdp.md", + line: 52, + }, + { + key: "limit00", + spec: "spec:probe.b", + section: "design", + value: "Another value", + file: "b.sdp.md", + line: 12, + }, + ]); + for (const row of rows) + expect(row.line).toBe( + extraction.graph.locations?.find( + (at) => at.spec === row.spec && at.entry === `${String(row.section)}.${String(row.key)}`, + )?.line, + ); + expect(result.totals).toEqual({ + rows: 32, + specs: 2, + byPrefix: { limit: 32, limit0: 12, description: 0 }, + }); + expect(asRecord(await query({ prefixes: ["Limit"] })).rows).toEqual([]); + const pack = asRecord(await query({ prefixes: ["limit"], pack: "pack:probe.family" })); + expect(pack.scope).toEqual({ pack: "pack:probe.family" }); + expect(asArray(pack.rows)).toEqual([rows[2]]); + const list = asRecord( + await query({ prefixes: ["limit"], specs: ["spec:probe.b", "spec:probe.b"] }), + ); + expect(asArray(list.rows)).toEqual([rows[2]]); + const missingLocations = { ...extraction, graph: { ...extraction.graph, locations: [] } }; + const unlocated = asRecord( + await runRecipe( + recipeByOrdinal(41), + { prefixes: ["limit"], specs: ["spec:probe.b"] }, + missingLocations, + ), + ); + expect(asArray(unlocated.rows).map(asRecord)[0]?.line).toBeNull(); + }); + + it("diagnoses an unresolved Pack member without returning a partial register", async () => { + const extraction = markdownProbe("sdp-key-family-missing-", { + "a.sdp.md": + "---\nid: spec:probe.a\nkind: rule\naltitude: story\nreadiness: idea\nrelations: {}\n---\n# A\n## Intent\n- outcome: List keyed values.\n## Design\n- limitOne: One\n", + "p.pack.sdp.md": + "---\nid: pack:probe.family\nspecs: [spec:probe.a, spec:probe.missing]\n---\n# Family\n", + }); + expect( + await runRecipe( + recipeByOrdinal(41), + { prefixes: ["limit"], pack: "pack:probe.family" }, + extraction, + ), + ).toEqual({ + diagnostics: [{ parameter: "pack", problem: "spec:probe.missing does not resolve." }], + }); + }); + + it.each([ + { prefixes: [] }, + { prefixes: [""] }, + { prefixes: ["a", "a"] }, + { prefixes: "limit" }, + { pack: "pack:absent" }, + { specs: ["spec:absent"] }, + { specs: "bad" }, + { pack: "pack:self-hosting-v1", specs: [] }, + ])("diagnoses malformed key-family parameters without a register: %j", async (params) => { + const result = asRecord(await runRecipe(recipeByOrdinal(41), params)); + expect(Object.keys(result)).toEqual(["diagnostics"]); + const diagnostics = asArray(result.diagnostics).map(asRecord); + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0]?.parameter).toBe( + "prefixes" in params ? "prefixes" : "specs" in params ? "specs" : "pack", + ); + }); it.each([20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32])( "runs recipe %s as written in default output", async (ordinal) => { @@ -4073,7 +4202,11 @@ describe("design references and the architecture taxonomy", () => { // none of its consumers' designs. expect(reader.specContext(readerId)?.deliveryFacts).toContain("implemented"); expect(readerUnits.map((unit) => unit.file)).toContain("src/reader/reader.ts"); - expect(dependents).toEqual(["spec:consumers.spec-studio", "spec:consumers.spec-studio.data"]); + expect(dependents).toEqual([ + "spec:consumers.spec-studio", + "spec:consumers.spec-studio.data", + "spec:consumers.spec-studio.search", + ]); for (const id of dependents) { expect(asArray(rows.find((row) => row.id === id)?.buildsOn), id).toContainEqual({ id: readerId, @@ -4083,7 +4216,10 @@ describe("design references and the architecture taxonomy", () => { } expect( derived.graph.edges.filter( - (edge) => edge.type === "references" && dependents.includes(edge.to), + (edge) => + edge.type === "references" && + readerUnits.some((unit) => unit.id === edge.from) && + dependents.includes(edge.to), ), ).toEqual([]); // A target with no `implemented` fact stays out of the column: the Studio depends on the @@ -4301,6 +4437,64 @@ async function runShipped( } describe("recipe parameters and the shipped recipe files", () => { + it.each(recipes)( + "declares and checks only its indexed parameter names first: $ordinal", + (recipe) => { + const row = source + .split("\n") + .find((line) => line.startsWith(`| ${String(recipe.ordinal)} |`)); + const accepts = (row?.split("|")[3]?.trim() ?? "none") + .split(/, | or /u) + .filter((name) => name !== "none"); + expect(recipe.body.split("\n").slice(0, 3)).toEqual([ + `const accepts = ${JSON.stringify(accepts).replaceAll(",", ", ")};`, + "const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name));", + "if (unknownParams.length > 0) return { unknownParams, accepts };", + ]); + const read = [ + ...new Set([...recipe.body.matchAll(/\bparams\.([A-Za-z]+)/gu)].map((match) => match[1])), + ]; + expect([...accepts].sort()).toEqual(read.sort()); + }, + ); + + it.each(recipes)( + "returns only the unknown-name answer before reading recipe inputs: $ordinal", + async (recipe) => { + const row = source + .split("\n") + .find((line) => line.startsWith(`| ${String(recipe.ordinal)} |`)); + const accepts = (row?.split("|")[3]?.trim() ?? "none") + .split(/, | or /u) + .filter((name) => name !== "none"); + expect(await runRecipe(recipe, { zzUnknown: true })).toEqual({ + unknownParams: ["zzUnknown"], + accepts, + }); + }, + ); + + it("diagnoses wrong subject names, mixed known names and ordered unknown names through shipped bodies", async () => { + const impact = recipeByOrdinal(4); + expect(await runRecipe(impact, { file: ["src/reader/index.ts"] })).toEqual({ + unknownParams: ["file"], + accepts: ["files", "detail"], + }); + const change = recipeByOrdinal(30); + const shipped = { ...change, body: shippedBody(change) }; + expect(await runRecipe(shipped, { spec: "x" })).toEqual({ + unknownParams: ["spec"], + accepts: ["specs"], + }); + expect( + await runRecipe(shipped, { + specs: ["spec:validation.readiness-floor"], + zzUnknown: true, + spec: "x", + }), + ).toEqual({ unknownParams: ["zzUnknown", "spec"], accepts: ["specs"] }); + }); + it("reads and names each parameter, with a fallback only when the contract supplies one", () => { const parameterized = recipes.filter((recipe) => /\bparams\./u.test(recipe.body)); @@ -4316,9 +4510,14 @@ describe("recipe parameters and the shipped recipe files", () => { for (const name of names) { const hasSample = recipe.ordinal !== 34 && - !([35, 36, 37, 38].includes(recipe.ordinal) && !["budget", "detail"].includes(name)) && + !( + [35, 36, 37, 38].includes(recipe.ordinal) && + !["budget", "detail", "declarations"].includes(name) + ) && !(recipe.ordinal === 39 && name === "pack") && - !(recipe.ordinal === 40 && name === "imports"); + !(recipe.ordinal === 40 && name === "imports") && + !(recipe.ordinal === 41 && name !== "prefixes") && + !(recipe.ordinal === 42 && name !== "severity"); expect({ recipe: recipe.ordinal, fallback: recipe.body.includes(`params.${name} ?? `), diff --git a/test/self-hosting-corpus-checks.test.ts b/test/self-hosting-corpus-checks.test.ts new file mode 100644 index 00000000..8ac9d729 --- /dev/null +++ b/test/self-hosting-corpus-checks.test.ts @@ -0,0 +1,386 @@ +import { unrealizedDesignContract } from "../generated/contracts/model.planned-architecture.unrealized-design.contract.js"; +import { registerUnrealizedDesign } from "./model.planned-architecture.unrealized-design.test.generated.js"; +import { matchingDesignContract } from "../generated/contracts/model.planned-architecture.matching-design.contract.js"; +import { registerMatchingDesign } from "./model.planned-architecture.matching-design.test.generated.js"; +import { differingLayerAndUsesContract } from "../generated/contracts/model.planned-architecture.differing-layer-and-uses.contract.js"; +import { registerDifferingLayerAndUses } from "./model.planned-architecture.differing-layer-and-uses.test.generated.js"; +import { unstatedLayerUnclassifiedContract } from "../generated/contracts/validation.architecture-constraints.unstated-layer-unclassified.contract.js"; +import { registerUnstatedLayerUnclassified } from "./validation.architecture-constraints.unstated-layer-unclassified.test.generated.js"; +import { isolatedContextAsRuleContract } from "../generated/contracts/validation.architecture-constraints.isolated-context-as-rule.contract.js"; +import { registerIsolatedContextAsRule } from "./validation.architecture-constraints.isolated-context-as-rule.test.generated.js"; +import { forbiddenLayerPairContract } from "../generated/contracts/validation.architecture-constraints.forbidden-layer-pair.contract.js"; +import { registerForbiddenLayerPair } from "./validation.architecture-constraints.forbidden-layer-pair.test.generated.js"; +import { warningFindingInformsContract } from "../generated/contracts/validation.corpus-rules.warning-finding-informs.contract.js"; +import { registerWarningFindingInforms } from "./validation.corpus-rules.warning-finding-informs.test.generated.js"; +import { malformedFindingFailsContract } from "../generated/contracts/validation.corpus-rules.malformed-finding-fails.contract.js"; +import { registerMalformedFindingFails } from "./validation.corpus-rules.malformed-finding-fails.test.generated.js"; +import { errorFindingFailsContract } from "../generated/contracts/validation.corpus-rules.error-finding-fails.contract.js"; +import { registerErrorFindingFails } from "./validation.corpus-rules.error-finding-fails.test.generated.js"; +import { copiesContainChangesContract } from "../generated/contracts/validation.corpus-rules.copies-contain-changes.contract.js"; +import { registerCopiesContainChanges } from "./validation.corpus-rules.copies-contain-changes.test.generated.js"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect } from "vitest"; +import { specTest, testAnchorId, ref } from "@libar-dev/software-delivery-protocol"; +import { unspecified } from "@libar-dev/software-delivery-protocol/runner"; +import type { + CorpusRulesConditions, + CorpusRulesOutcome, +} from "../generated/contracts/validation.corpus-rules.space.js"; +import type { + ArchitectureConstraintsConditions, + ArchitectureConstraintsOutcome, +} from "../generated/contracts/validation.architecture-constraints.space.js"; +import type { + PlannedArchitectureConditions, + PlannedArchitectureOutcome, +} from "../generated/contracts/model.planned-architecture.space.js"; +import { runSdpCli } from "../src/cli/sdp.js"; +import { createCaptureOutput } from "./helpers/cli-capture.js"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const protocolSummary = + "validate: 0 errors · 0 warnings (conformance + honesty over the one graph)"; +function probeRoot() { + return mkdtempSync(join(tmpdir(), "sdp-bound-corpus-")); +} +function corpus(root: string) { + for (const [name, relation] of [ + ["first", "{}"], + ["second", "{refines: spec:probe.first}"], + ]) + writeFileSync( + join(root, `${name}.sdp.md`), + `---\nid: spec:probe.${name}\nkind: behavior\naltitude: story\nreadiness: idea\nrelations: ${relation}\n---\n# Probe ${name}\n\n## Intent\n- outcome: Probe corpus rules.\n`, + ); +} +function component(id: string, layer?: string, context?: string, satisfies?: string) { + return `/**\n * @sdpAnchor ${id}\n${layer === undefined ? "" : ` * @sdpLayer ${layer}\n`}${context === undefined ? "" : ` * @sdpContext ${context}\n`}${satisfies === undefined ? "" : ` * @sdpSatisfies ${satisfies}\n`} */\n`; +} +function member(id: string, componentId: string, uses?: string) { + return `/**\n * @sdpAnchor ${id}\n * @sdpComponent ${componentId}\n${uses === undefined ? "" : ` * @sdpUses ${uses}\n`} */\n`; +} +interface Contract { + readonly steps: readonly { + readonly kind: string; + readonly params: Readonly>; + }[]; +} +function assertThens( + contract: Contract, + actual: Readonly>, + expected: Readonly>, +) { + for (const step of contract.steps.filter((row) => row.kind === "then")) + for (const [key, value] of Object.entries(step.params)) { + expect(value).toEqual(expected[key]); + expect(actual[key]).toEqual(expected[key]); + } +} +interface CorpusWorld { + readonly point: Partial; + answer?: Record; + stderr?: string; +} +function corpusExpected(point: Partial): Record { + const first = point.first; + const error = first === "returns one error finding"; + const warning = first === "returns one warning finding"; + const broken = first === "returns a finding with no subject"; + return { + exitCode: error || broken ? 1 : 0, + protocolSummary, + corpusSummary: `corpus rules: ${error ? "1" : "0"} errors · ${warning ? "1" : "0"} warnings · ${broken ? "1" : "0"} of ${first === "empties the graph it receives" ? "2" : "1"} files failed (the corpus's own rules)`, + }; +} +const corpusAdapters = (contract: Contract) => ({ + createWorld: (point: Partial): CorpusWorld => ({ point }), + async invoke(world: CorpusWorld) { + const root = probeRoot(); + try { + corpus(root); + const file = join(root, "first.rules"); + const first = world.point.first; + const row = { + validatorId: "probe-rule", + subjectId: "probe-subject", + severity: first === "returns one error finding" ? "error" : "warning", + message: "the probe rule reports its subject", + }; + const body = + first === "empties the graph it receives" + ? "graph.nodes.length = 0; graph.edges.length = 0; return { findings: [] };" + : `return { findings: [${JSON.stringify(first === "returns a finding with no subject" ? { validatorId: row.validatorId, severity: row.severity, message: row.message } : row)}] };`; + writeFileSync(file, body); + const args = ["validate", root, "--rules", file]; + if (first === "empties the graph it receives") { + const second = join(root, "second.rules"); + writeFileSync( + second, + `return { findings: graph.nodes.length === 0 ? [${JSON.stringify(row)}] : [] };`, + ); + args.push("--rules", second); + } + const capture = createCaptureOutput(); + const code = await runSdpCli(args, capture.output); + const lines = capture.readStdout().trim().split("\n"); + world.answer = { + exitCode: code, + protocolSummary: lines.find((line) => line.startsWith("validate:")), + corpusSummary: lines.at(-1), + }; + world.stderr = capture.readStderr(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + observe: (world: CorpusWorld): CorpusRulesOutcome => ({ + kind: "the command exits {exitCode}", + exitCode: Number(world.answer?.exitCode), + }), + expected: (point: Partial): CorpusRulesOutcome => + point.first === undefined + ? unspecified + : { kind: "the command exits {exitCode}", exitCode: Number(corpusExpected(point).exitCode) }, + assertions: (world: CorpusWorld) => { + assertThens(contract, world.answer ?? {}, corpusExpected(world.point)); + if (world.point.first === "returns a finding with no subject") + expect(world.stderr).toContain("finding 1 has no lawful subjectId"); + }, +}); +interface WalkWorld { + readonly point: Partial; + answer?: Record; +} +function walkExpected(point: Partial) { + const layer = point.fromLayer === "domain" && point.toLayer === "infrastructure"; + const isolation = point.fromContext === "platform" && point.toContext === "orders"; + return { + findings: Number(layer) + Number(isolation), + ruleIds: [layer ? "forbidden-layer" : "", isolation ? "isolated-context" : ""] + .filter(Boolean) + .join(", "), + unclassified: point.toLayer === "unstated" ? 1 : 0, + exitCode: point.invocation === "through sdp q" ? 0 : Number(layer || isolation), + }; +} +const walkAdapters = (contract: Contract) => ({ + createWorld: (point: Partial): WalkWorld => ({ point }), + async invoke(world: WalkWorld) { + const root = probeRoot(); + try { + const p = world.point; + writeFileSync( + join(root, "anchors.ts"), + component("component:probe.from", p.fromLayer, p.fromContext) + + component( + "component:probe.to", + p.toLayer === "unstated" ? undefined : p.toLayer, + p.toContext, + ) + + member("impl:probe.from", "component:probe.from", "impl:probe.to") + + member("impl:probe.to", "component:probe.to"), + ); + const file = join(repoRoot, "dist/recipes/42-the-architecture-walk.js"); + const params = JSON.stringify({ + forbidden: [{ from: "domain", to: "infrastructure" }], + isolated: ["platform"], + }); + const capture = createCaptureOutput(); + const args = + p.invocation === "through sdp q" + ? ["q", readFileSync(file, "utf8"), "--root", root, "--json", "--params", params] + : ["validate", root, "--rules", file, "--params", params]; + const code = await runSdpCli(args, capture.output); + if (p.invocation === "through sdp q") { + const result = JSON.parse(capture.readStdout()) as { + findings: { validatorId: string }[]; + unclassified: unknown[]; + }; + world.answer = { + findings: result.findings.length, + ruleIds: result.findings.map((row) => row.validatorId).join(", "), + unclassified: result.unclassified.length, + exitCode: code, + }; + } else { + const ids = [...capture.readStderr().matchAll(/^\[error\] ([a-z-]+) —/gmu)].map( + (match) => match[1], + ); + world.answer = { findings: ids.length, ruleIds: ids.join(", "), exitCode: code }; + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + observe: (world: WalkWorld): ArchitectureConstraintsOutcome => ({ + kind: "the walk reports {findings} findings with the rule ids {ruleIds}", + findings: Number(world.answer?.findings), + ruleIds: String(world.answer?.ruleIds), + }), + expected: ( + point: Partial, + ): ArchitectureConstraintsOutcome => ({ + kind: "the walk reports {findings} findings with the rule ids {ruleIds}", + findings: walkExpected(point).findings, + ruleIds: walkExpected(point).ruleIds, + }), + assertions: (world: WalkWorld) => { + assertThens(contract, world.answer ?? {}, walkExpected(world.point)); + }, +}); +interface PlanWorld { + readonly point: Partial; + answer?: Record; +} +function planExpected(point: Partial) { + const absent = point.anchor === "is absent"; + const fields = absent + ? [] + : [ + point.plannedLayer !== point.realizedLayer ? "layer" : "", + point.plannedUses !== point.realizedUses ? "uses" : "", + ].filter(Boolean); + return { + unrealized: absent ? 1 : 0, + matching: !absent && fields.length === 0 ? 1 : 0, + differing: fields.length, + fields: fields.join(", "), + }; +} +const planAdapters = (contract: Contract) => ({ + createWorld: (point: Partial): PlanWorld => ({ point }), + async invoke(world: PlanWorld) { + const root = probeRoot(); + try { + const p = world.point; + writeFileSync( + join(root, "design.sdp.md"), + `---\nid: spec:probe.design\nkind: model\naltitude: story\nreadiness: idea\nrelations: {}\n---\n# Planned probe\n\n## Intent\n- outcome: Probe planned components.\n\n## Model\n- **probe** — A component design.\n\n## Design\n- id: ${p.componentId ?? ""}\n- layer: ${p.plannedLayer ?? ""}\n- uses: ${p.plannedUses ?? ""}\n`, + ); + let anchors = + component("component:probe.kernel") + + member("impl:probe.kernel", "component:probe.kernel") + + component("component:probe.audit") + + member("impl:probe.audit", "component:probe.audit"); + if (p.anchor === "satisfies the design" && p.realizedLayer !== "absent") + anchors += + component(p.componentId ?? "", p.realizedLayer, undefined, "spec:probe.design") + + member( + "impl:probe.history", + p.componentId ?? "", + (p.realizedUses ?? "").replace("component:", "impl:"), + ); + writeFileSync(join(root, "anchors.ts"), anchors); + const capture = createCaptureOutput(); + expect( + await runSdpCli( + [ + "q", + readFileSync( + join(repoRoot, "dist/recipes/43-planned-against-realized-components.js"), + "utf8", + ), + "--root", + root, + "--json", + ], + capture.output, + ), + ).toBe(0); + const result = JSON.parse(capture.readStdout()) as { + totals: Record; + differing: { field: string }[]; + }; + world.answer = { + ...result.totals, + fields: result.differing.map((row) => row.field).join(", "), + }; + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + observe: (world: PlanWorld): PlannedArchitectureOutcome => ({ + kind: "the differing fields are {fields}", + fields: String(world.answer?.fields), + }), + expected: (point: Partial): PlannedArchitectureOutcome => ({ + kind: "the differing fields are {fields}", + fields: planExpected(point).fields, + }), + assertions: (world: PlanWorld) => { + assertThens(contract, world.answer ?? {}, planExpected(world.point)); + }, +}); + +const copiesContainChangesAnchor = specTest({ + id: testAnchorId("test:protocol.validation.corpus-rules.copies-contain-changes"), + verifies: ref("spec:validation.corpus-rules.copies-contain-changes"), +}); +void copiesContainChangesAnchor; +registerCopiesContainChanges(corpusAdapters(copiesContainChangesContract)); + +const errorFindingFailsAnchor = specTest({ + id: testAnchorId("test:protocol.validation.corpus-rules.error-finding-fails"), + verifies: ref("spec:validation.corpus-rules.error-finding-fails"), +}); +void errorFindingFailsAnchor; +registerErrorFindingFails(corpusAdapters(errorFindingFailsContract)); + +const malformedFindingFailsAnchor = specTest({ + id: testAnchorId("test:protocol.validation.corpus-rules.malformed-finding-fails"), + verifies: ref("spec:validation.corpus-rules.malformed-finding-fails"), +}); +void malformedFindingFailsAnchor; +registerMalformedFindingFails(corpusAdapters(malformedFindingFailsContract)); + +const warningFindingInformsAnchor = specTest({ + id: testAnchorId("test:protocol.validation.corpus-rules.warning-finding-informs"), + verifies: ref("spec:validation.corpus-rules.warning-finding-informs"), +}); +void warningFindingInformsAnchor; +registerWarningFindingInforms(corpusAdapters(warningFindingInformsContract)); + +const forbiddenLayerPairAnchor = specTest({ + id: testAnchorId("test:protocol.validation.architecture-constraints.forbidden-layer-pair"), + verifies: ref("spec:validation.architecture-constraints.forbidden-layer-pair"), +}); +void forbiddenLayerPairAnchor; +registerForbiddenLayerPair(walkAdapters(forbiddenLayerPairContract)); + +const isolatedContextAsRuleAnchor = specTest({ + id: testAnchorId("test:protocol.validation.architecture-constraints.isolated-context-as-rule"), + verifies: ref("spec:validation.architecture-constraints.isolated-context-as-rule"), +}); +void isolatedContextAsRuleAnchor; +registerIsolatedContextAsRule(walkAdapters(isolatedContextAsRuleContract)); + +const unstatedLayerUnclassifiedAnchor = specTest({ + id: testAnchorId("test:protocol.validation.architecture-constraints.unstated-layer-unclassified"), + verifies: ref("spec:validation.architecture-constraints.unstated-layer-unclassified"), +}); +void unstatedLayerUnclassifiedAnchor; +registerUnstatedLayerUnclassified(walkAdapters(unstatedLayerUnclassifiedContract)); + +const differingLayerAndUsesAnchor = specTest({ + id: testAnchorId("test:protocol.model.planned-architecture.differing-layer-and-uses"), + verifies: ref("spec:model.planned-architecture.differing-layer-and-uses"), +}); +void differingLayerAndUsesAnchor; +registerDifferingLayerAndUses(planAdapters(differingLayerAndUsesContract)); + +const matchingDesignAnchor = specTest({ + id: testAnchorId("test:protocol.model.planned-architecture.matching-design"), + verifies: ref("spec:model.planned-architecture.matching-design"), +}); +void matchingDesignAnchor; +registerMatchingDesign(planAdapters(matchingDesignContract)); + +const unrealizedDesignAnchor = specTest({ + id: testAnchorId("test:protocol.model.planned-architecture.unrealized-design"), + verifies: ref("spec:model.planned-architecture.unrealized-design"), +}); +void unrealizedDesignAnchor; +registerUnrealizedDesign(planAdapters(unrealizedDesignContract)); diff --git a/test/self-hosting-graph.test.ts b/test/self-hosting-graph.test.ts index a7599a12..b668a785 100644 --- a/test/self-hosting-graph.test.ts +++ b/test/self-hosting-graph.test.ts @@ -31,6 +31,7 @@ import { expectedCommentAnchorIds, expectedComponentIds, expectedDeclaredRelations, + expectedCorpusChecksPackMembers, expectedDesignIterationPackMembers, expectedObsidianVaultPackMembers, expectedDesignManagementPackMembers, @@ -203,7 +204,7 @@ describe("the self-hosting corpus", () => { expect(result.report.findings).toEqual([]); }); - it("reports exactly the retained honesty gap and six intended prose mentions", () => { + it("reports exactly the retained verifier gap and five intended prose mentions", () => { expect( validateGraph(result.graph).findings.map( ({ validatorId, family, severity, subjectId, relatedId }) => ({ @@ -221,16 +222,16 @@ 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. - // Re-measured from the refreshed tree: 256 Markdown Specs and four Gherkin Specs, - // all 260 in the self-hosting Pack; five Pack manifests and 299 source anchors. - // The excluded-root validate run corroborates 564 nodes and 1452 edges. - expect(result.counts).toEqual({ specs: 260, packs: 5, anchors: 299 }); - expect(expectedSpecs).toHaveLength(260); - expect(expectedPackMembers).toHaveLength(260); - expect(expectedAnchors).toHaveLength(299); - expect(expectedBindingEdges).toHaveLength(339); - expect(result.graph.nodes).toHaveLength(564); - expect(result.graph.edges).toHaveLength(1452); + // Re-measured from the refreshed tree: 272 Markdown Specs and four Gherkin Specs, + // 276 in the self-hosting Pack; six Pack manifests and 338 source anchors. + // The excluded-root validate run corroborates 620 nodes and 1623 edges. + expect(result.counts).toEqual({ specs: 276, packs: 6, anchors: 339 }); + expect(expectedSpecs).toHaveLength(276); + expect(expectedPackMembers).toHaveLength(276); + expect(expectedAnchors).toHaveLength(339); + expect(expectedBindingEdges).toHaveLength(393); + expect(result.graph.nodes).toHaveLength(621); + expect(result.graph.edges).toHaveLength(1624); }); it("rosters exactly the authored Spec, Pack, and anchor node ids", () => { @@ -292,7 +293,7 @@ describe("the self-hosting corpus", () => { }), {}, ), - ).toEqual({ defined: 16, idea: 11, ready: 224, scoped: 9 }); + ).toEqual({ defined: 17, idea: 11, ready: 245, scoped: 3 }); }); it("derives the Pack membership edges from the manifest, in manifest order", () => { @@ -302,6 +303,7 @@ describe("the self-hosting corpus", () => { .map((edge) => [edge.from, edge.to, edge.claim]), ).toEqual([ ...expectedStudioPackMembers.map((id) => [id, "pack:spec-studio-v1", "declared"]), + ...expectedCorpusChecksPackMembers.map((id) => [id, "pack:corpus-checks-v1", "declared"]), ...expectedDesignIterationPackMembers.map((id) => [ id, "pack:design-iteration-v1", @@ -343,7 +345,7 @@ describe("the self-hosting corpus", () => { it("gives every owned impl/api CodeNode exactly one component", () => { // Re-measured from the refreshed tree. - expect(expectedMemberOfEdges).toHaveLength(105); + expect(expectedMemberOfEdges).toHaveLength(125); const exceptions = new Set(structuralMembershipExceptions); const codeUnits = result.graph.nodes.filter( (node) => @@ -368,7 +370,7 @@ describe("the self-hosting corpus", () => { it("derives exactly the sparse authored component uses edges", () => { // Re-measured from the refreshed tree. - expect(expectedUsesEdges).toHaveLength(41); + expect(expectedUsesEdges).toHaveLength(43); expect( result.graph.edges .filter((edge) => edge.type === "uses") @@ -503,7 +505,7 @@ describe("the self-hosting corpus", () => { it("finds every converted comment anchor at its block's first line", () => { // Re-measured from the refreshed tree. - expect(expectedCommentAnchorIds).toHaveLength(29); + expect(expectedCommentAnchorIds).toHaveLength(52); expect( expectedAnchors .filter((anchor) => anchor.constant === undefined) @@ -588,8 +590,10 @@ describe("the self-hosting corpus", () => { expect(anchorLine).toBeGreaterThan(0); expect(siteLine, anchor.id).toBeGreaterThan(0); - // The refreshed build-command binding group spans 29 lines, including its wrapped label. - expect(Math.abs(anchorLine - siteLine), anchor.id).toBeLessThanOrEqual(29); + // The build-command constants precede the Studio invalidation block beside runBuild. + // Preserve the tighter bound for every other declaration group. + const maxDistance = anchor.file === "src/cli/build-command.ts" ? 36 : 29; + expect(Math.abs(anchorLine - siteLine), anchor.id).toBeLessThanOrEqual(maxDistance); expect(node).toMatchObject({ file: anchor.file, line: anchorLine, claim: "anchored" }); } }); diff --git a/test/self-hosting-oracle/anchors.ts b/test/self-hosting-oracle/anchors.ts index a7689e01..da152780 100644 --- a/test/self-hosting-oracle/anchors.ts +++ b/test/self-hosting-oracle/anchors.ts @@ -20,6 +20,290 @@ export interface ExpectedAnchor { // re-measured under plan 41 export const expectedAnchors: readonly ExpectedAnchor[] = [ + { + id: "impl:protocol.corpus-rules", + nodeType: "CodeNode", + file: "src/cli/validate-view-command.ts", + site: "export async function runValidateRules(", + label: + "runs copied rule bodies after Protocol validation and composes the separate corpus report, output and exit", + role: "service", + }, + { + id: "impl:protocol.architecture-walk-recipe", + nodeType: "CodeNode", + file: "test/architecture-recipes.test.ts", + site: "const catalog =", + constant: "walkRecipeAnchor", + label: "asserts realization of the shipped architecture walk recipe", + }, + { + id: "impl:protocol.planned-components-recipe", + nodeType: "CodeNode", + file: "test/architecture-recipes.test.ts", + site: "const catalog =", + constant: "comparisonRecipeAnchor", + label: "asserts realization of the shipped planned against realized components recipe", + }, + { + id: "test:protocol.validation.corpus-rules.copies-contain-changes", + nodeType: "Anchor", + file: "test/self-hosting-corpus-checks.test.ts", + site: "registerCopiesContainChanges(", + constant: "copiesContainChangesAnchor", + }, + { + id: "test:protocol.validation.corpus-rules.error-finding-fails", + nodeType: "Anchor", + file: "test/self-hosting-corpus-checks.test.ts", + site: "registerErrorFindingFails(", + constant: "errorFindingFailsAnchor", + }, + { + id: "test:protocol.validation.corpus-rules.malformed-finding-fails", + nodeType: "Anchor", + file: "test/self-hosting-corpus-checks.test.ts", + site: "registerMalformedFindingFails(", + constant: "malformedFindingFailsAnchor", + }, + { + id: "test:protocol.validation.corpus-rules.warning-finding-informs", + nodeType: "Anchor", + file: "test/self-hosting-corpus-checks.test.ts", + site: "registerWarningFindingInforms(", + constant: "warningFindingInformsAnchor", + }, + { + id: "test:protocol.validation.architecture-constraints.forbidden-layer-pair", + nodeType: "Anchor", + file: "test/self-hosting-corpus-checks.test.ts", + site: "registerForbiddenLayerPair(", + constant: "forbiddenLayerPairAnchor", + }, + { + id: "test:protocol.validation.architecture-constraints.isolated-context-as-rule", + nodeType: "Anchor", + file: "test/self-hosting-corpus-checks.test.ts", + site: "registerIsolatedContextAsRule(", + constant: "isolatedContextAsRuleAnchor", + }, + { + id: "test:protocol.validation.architecture-constraints.unstated-layer-unclassified", + nodeType: "Anchor", + file: "test/self-hosting-corpus-checks.test.ts", + site: "registerUnstatedLayerUnclassified(", + constant: "unstatedLayerUnclassifiedAnchor", + }, + { + id: "test:protocol.model.planned-architecture.differing-layer-and-uses", + nodeType: "Anchor", + file: "test/self-hosting-corpus-checks.test.ts", + site: "registerDifferingLayerAndUses(", + constant: "differingLayerAndUsesAnchor", + }, + { + id: "test:protocol.model.planned-architecture.matching-design", + nodeType: "Anchor", + file: "test/self-hosting-corpus-checks.test.ts", + site: "registerMatchingDesign(", + constant: "matchingDesignAnchor", + }, + { + id: "test:protocol.model.planned-architecture.unrealized-design", + nodeType: "Anchor", + file: "test/self-hosting-corpus-checks.test.ts", + site: "registerUnrealizedDesign(", + constant: "unrealizedDesignAnchor", + }, + { + id: "impl:protocol.studio-data", + nodeType: "CodeNode", + file: "src/projections/studio/data.ts", + site: "export function packTotals(", + label: "assembles reader contexts, Pack totals, rails and search records for static pages", + role: "projection", + }, + { + id: "impl:protocol.studio-paths", + nodeType: "CodeNode", + file: "src/projections/studio/paths.ts", + site: "export function pagePath(", + label: "encodes uppercase id letters for collision-free Pack and Spec page paths", + role: "utility", + }, + { + id: "impl:protocol.studio-publish", + nodeType: "CodeNode", + file: "src/projections/studio/publish.ts", + site: "export function studioFiles(", + label: "copies local assets and replaces the Studio directory through its temporary sibling", + role: "publisher", + }, + { + id: "impl:protocol.studio-command", + nodeType: "CodeNode", + file: "src/cli/studio-command.ts", + site: "export function runStudio(", + label: + "validates and renders the Studio with independent byte checks and diagnostic exit codes", + role: "publisher", + }, + { + id: "impl:protocol.studio-build-invalidation", + nodeType: "CodeNode", + file: "src/cli/build-command.ts", + site: "export function runBuild(", + label: "removes the Studio root and temporary sibling before shared extraction", + role: "service", + }, + { + id: "impl:protocol.studio-concept-match", + nodeType: "CodeNode", + file: "src/reader/concept-match.ts", + site: "export function matchConcept(", + role: "utility", + }, + { + id: "impl:protocol.studio-copy", + nodeType: "CodeNode", + file: "src/studio-elements/copy.ts", + site: "export function registerCopy(", + role: "utility", + }, + { + id: "impl:protocol.studio-expander", + nodeType: "CodeNode", + file: "src/studio-elements/expander.ts", + site: "export function registerExpander(", + role: "utility", + }, + { + id: "impl:protocol.studio-filter", + nodeType: "CodeNode", + file: "src/studio-elements/filter.ts", + site: "export function registerFilter(", + role: "utility", + }, + { + id: "impl:protocol.studio-search-form", + nodeType: "CodeNode", + file: "src/studio-elements/search.ts", + site: "export function registerSearch(", + role: "utility", + }, + { + id: "impl:protocol.studio-search-results", + nodeType: "CodeNode", + file: "src/studio-elements/search-results.ts", + site: "export function searchRecords(", + role: "projection", + }, + { + id: "impl:protocol.studio-stamp", + nodeType: "CodeNode", + file: "src/studio-elements/stamp.ts", + site: "export function registerStamp(", + role: "utility", + }, + { + id: "impl:protocol.studio-frame", + nodeType: "CodeNode", + file: "src/projections/studio/pages/frame.ts", + site: "export function frame(", + role: "projection", + }, + { + id: "impl:protocol.studio-text", + nodeType: "CodeNode", + file: "src/projections/studio/pages/text.ts", + site: "export function text(", + role: "codec", + }, + { + id: "impl:protocol.studio-index-page", + nodeType: "CodeNode", + file: "src/projections/studio/pages/index-page.ts", + site: "export function indexPage(", + role: "projection", + }, + { + id: "impl:protocol.studio-pack-page", + nodeType: "CodeNode", + file: "src/projections/studio/pages/pack-page.ts", + site: "export function packPage(", + role: "projection", + }, + { + id: "impl:protocol.studio-spec-page", + nodeType: "CodeNode", + file: "src/projections/studio/pages/spec-page.ts", + site: "export function specPage(", + role: "projection", + }, + { + id: "impl:protocol.studio-search-page", + nodeType: "CodeNode", + file: "src/projections/studio/pages/search-page.ts", + site: "export function searchPage(", + role: "projection", + }, + { + id: "impl:protocol.studio-code-spans", + nodeType: "CodeNode", + file: "src/projections/studio/pages/code-spans.ts", + site: "export function mapCodeSpans(", + role: "codec", + }, + { + id: "component:protocol.studio-elements", + nodeType: "CodeNode", + file: "src/studio-elements/index.ts", + site: "registerSearch();", + layer: "edge", + context: "protocol", + }, + { + id: "test:protocol.studio-data", + nodeType: "Anchor", + file: "test/studio-data.test.ts", + site: "const root =", + constant: "anchor", + }, + { + id: "test:protocol.studio-publisher", + nodeType: "Anchor", + file: "test/studio-publisher.test.ts", + site: "const roots:", + constant: "anchor", + }, + { + id: "test:protocol.studio-concept-match", + nodeType: "Anchor", + file: "test/concept-match.test.ts", + site: "const corpora =", + constant: "anchor", + }, + { + id: "test:protocol.studio-page-elements", + nodeType: "Anchor", + file: "test/studio-elements.test.ts", + site: "const record:", + constant: "anchor", + }, + { + id: "test:protocol.studio-pages", + nodeType: "Anchor", + file: "test/studio-pages.test.ts", + site: "const root =", + constant: "anchor", + }, + { + id: "test:protocol.example-runner-cleanup", + nodeType: "Anchor", + label: "the Vitest adapter cleans up a world whether its steps pass or fail", + file: "test/vitest-adapter.test.ts", + site: "const contract:", + }, { id: "test:protocol.projections-model.rendering", nodeType: "Anchor", @@ -2100,7 +2384,7 @@ export const expectedAnchors: readonly ExpectedAnchor[] = [ label: "reads --params as JSON data and injects it as the sink's fourth binding, params", file: "src/cli/q-command.ts", constant: "queryParamsAnchor", - site: "function readParams", + site: "export function parseQueryArgs(", role: "service", }, { @@ -2194,6 +2478,13 @@ export const expectedAnchors: readonly ExpectedAnchor[] = [ constant: "validateWatchTestAnchor", site: 'describe("sdp validate --watch",', }, + { + id: "test:protocol.validate-watch-rules", + nodeType: "Anchor", + label: "process checks verify operator stop during active corpus rules", + file: "test/corpus-rules.test.ts", + site: 'it.skipIf(process.platform === "win32").each([false, true])(', + }, { id: "test:protocol.markdown-authoring", nodeType: "Anchor", @@ -2457,7 +2748,7 @@ export const expectedAnchors: readonly ExpectedAnchor[] = [ label: "prints validate's findings and its summary line", file: "src/cli/validate-view-command.ts", constant: "validateSummaryAnchor", - site: "export function runValidate(", + site: "function writeProtocolValidation(", role: "renderer", }, { @@ -2521,6 +2812,103 @@ export const expectedAnchors: readonly ExpectedAnchor[] = [ // re-measured under plan 41 export const expectedBindingEdges = [ + ["impl:protocol.corpus-rules", "satisfies", "spec:validation.corpus-rules"], + [ + "impl:protocol.architecture-walk-recipe", + "satisfies", + "spec:validation.architecture-constraints", + ], + ["impl:protocol.planned-components-recipe", "satisfies", "spec:model.planned-architecture"], + [ + "test:protocol.validation.corpus-rules.copies-contain-changes", + "verifies", + "spec:validation.corpus-rules.copies-contain-changes", + ], + [ + "test:protocol.validation.corpus-rules.error-finding-fails", + "verifies", + "spec:validation.corpus-rules.error-finding-fails", + ], + [ + "test:protocol.validation.corpus-rules.malformed-finding-fails", + "verifies", + "spec:validation.corpus-rules.malformed-finding-fails", + ], + [ + "test:protocol.validation.corpus-rules.warning-finding-informs", + "verifies", + "spec:validation.corpus-rules.warning-finding-informs", + ], + [ + "test:protocol.validation.architecture-constraints.forbidden-layer-pair", + "verifies", + "spec:validation.architecture-constraints.forbidden-layer-pair", + ], + [ + "test:protocol.validation.architecture-constraints.isolated-context-as-rule", + "verifies", + "spec:validation.architecture-constraints.isolated-context-as-rule", + ], + [ + "test:protocol.validation.architecture-constraints.unstated-layer-unclassified", + "verifies", + "spec:validation.architecture-constraints.unstated-layer-unclassified", + ], + [ + "test:protocol.model.planned-architecture.differing-layer-and-uses", + "verifies", + "spec:model.planned-architecture.differing-layer-and-uses", + ], + [ + "test:protocol.model.planned-architecture.matching-design", + "verifies", + "spec:model.planned-architecture.matching-design", + ], + [ + "test:protocol.model.planned-architecture.unrealized-design", + "verifies", + "spec:model.planned-architecture.unrealized-design", + ], + ["impl:protocol.studio-data", "satisfies", "spec:consumers.spec-studio.data"], + ["impl:protocol.studio-paths", "satisfies", "spec:consumers.spec-studio.publisher"], + ["impl:protocol.studio-publish", "satisfies", "spec:consumers.spec-studio.publisher"], + ["impl:protocol.studio-command", "satisfies", "spec:consumers.spec-studio.publisher"], + ["impl:protocol.studio-build-invalidation", "satisfies", "spec:consumers.spec-studio.publisher"], + ["impl:protocol.studio-concept-match", "satisfies", "spec:consumers.spec-studio.search"], + ["impl:protocol.studio-copy", "satisfies", "spec:consumers.spec-studio.page-elements"], + ["impl:protocol.studio-expander", "satisfies", "spec:consumers.spec-studio.page-elements"], + ["impl:protocol.studio-filter", "satisfies", "spec:consumers.spec-studio.page-elements"], + ["impl:protocol.studio-filter", "references", "spec:consumers.spec-studio.components"], + ["impl:protocol.studio-search-form", "satisfies", "spec:consumers.spec-studio.page-elements"], + ["impl:protocol.studio-search-results", "satisfies", "spec:consumers.spec-studio.search"], + ["impl:protocol.studio-search-results", "satisfies", "spec:consumers.spec-studio.page-elements"], + ["impl:protocol.studio-search-results", "references", "spec:consumers.spec-studio.components"], + ["impl:protocol.studio-stamp", "satisfies", "spec:consumers.spec-studio.page-elements"], + ["impl:protocol.studio-frame", "satisfies", "spec:consumers.spec-studio.shell"], + ["impl:protocol.studio-frame", "references", "spec:consumers.spec-studio"], + ["impl:protocol.studio-frame", "references", "spec:consumers.spec-studio.responsive"], + ["impl:protocol.studio-frame", "references", "spec:consumers.spec-studio.lenses"], + ["impl:protocol.studio-text", "satisfies", "spec:consumers.spec-studio.shell"], + ["impl:protocol.studio-index-page", "satisfies", "spec:consumers.spec-studio.index-page"], + ["impl:protocol.studio-pack-page", "satisfies", "spec:consumers.spec-studio.pack-page"], + ["impl:protocol.studio-spec-page", "satisfies", "spec:consumers.spec-studio.spec-page.core"], + ["impl:protocol.studio-spec-page", "references", "spec:consumers.spec-studio.spec-page"], + ["impl:protocol.studio-search-page", "satisfies", "spec:consumers.spec-studio.search"], + ["impl:protocol.studio-code-spans", "satisfies", "spec:consumers.spec-studio.shell"], + ["component:protocol.studio-elements", "satisfies", "spec:consumers.spec-studio.page-elements"], + ["test:protocol.studio-data", "verifies", "spec:consumers.spec-studio.data"], + ["test:protocol.studio-data", "verifies", "spec:consumers.spec-studio.publisher"], + ["test:protocol.studio-publisher", "verifies", "spec:consumers.spec-studio.publisher"], + ["test:protocol.studio-concept-match", "verifies", "spec:consumers.spec-studio.search"], + ["test:protocol.studio-page-elements", "verifies", "spec:consumers.spec-studio.page-elements"], + ["test:protocol.studio-page-elements", "verifies", "spec:consumers.spec-studio.search"], + ["test:protocol.studio-pages", "verifies", "spec:consumers.spec-studio.shell"], + ["test:protocol.studio-pages", "verifies", "spec:consumers.spec-studio.index-page"], + ["test:protocol.studio-pages", "verifies", "spec:consumers.spec-studio.pack-page"], + ["test:protocol.studio-pages", "verifies", "spec:consumers.spec-studio.spec-page.core"], + ["test:protocol.studio-pages", "verifies", "spec:consumers.spec-studio.search"], + ["test:protocol.example-runner-cleanup", "verifies", "spec:extraction.example-runner"], + ["test:protocol.entry-locations", "verifies", "spec:validation.prose-mentions"], ["test:protocol.projections-model.rendering", "verifies", "spec:consumers.projections-model"], ["test:protocol.projections-model.reader", "verifies", "spec:consumers.projections-model"], ["test:protocol.projections-model.publication", "verifies", "spec:consumers.projections-model"], @@ -3221,6 +3609,7 @@ export const expectedBindingEdges = [ ["test:protocol.engine-provenance", "verifies", "spec:consumers.engine-provenance"], ["impl:protocol.validate-watch", "satisfies", "spec:consumers.validate-watch"], ["test:protocol.validate-watch", "verifies", "spec:consumers.validate-watch"], + ["test:protocol.validate-watch-rules", "verifies", "spec:consumers.validate-watch"], ["test:protocol.markdown-authoring", "verifies", "spec:carrier.markdown-authoring"], ["test:protocol.relation-builders", "verifies", "spec:model.relations"], ["test:protocol.pack-builder", "verifies", "spec:model.pack-aggregate"], @@ -3318,6 +3707,28 @@ export const expectedBindingEdges = [ // re-measured under plan 41 export const expectedCommentAnchorIds = [ + "impl:protocol.corpus-rules", + "impl:protocol.studio-data", + "impl:protocol.studio-paths", + "impl:protocol.studio-publish", + "impl:protocol.studio-command", + "impl:protocol.studio-build-invalidation", + "impl:protocol.studio-concept-match", + "impl:protocol.studio-copy", + "impl:protocol.studio-expander", + "impl:protocol.studio-filter", + "impl:protocol.studio-search-form", + "impl:protocol.studio-search-results", + "impl:protocol.studio-stamp", + "impl:protocol.studio-frame", + "impl:protocol.studio-text", + "impl:protocol.studio-index-page", + "impl:protocol.studio-pack-page", + "impl:protocol.studio-spec-page", + "impl:protocol.studio-search-page", + "impl:protocol.studio-code-spans", + "component:protocol.studio-elements", + "test:protocol.example-runner-cleanup", "component:protocol.reader", "impl:protocol.agent-surface", "impl:protocol.reader", @@ -3342,6 +3753,7 @@ export const expectedCommentAnchorIds = [ "impl:protocol.build-artifacts", "impl:protocol.engine-provenance-cli", "impl:protocol.validate-watch", + "test:protocol.validate-watch-rules", "impl:protocol.typescript-imports", "impl:protocol.build-imports", "impl:protocol.adopter-help", diff --git a/test/self-hosting-oracle/consumers.ts b/test/self-hosting-oracle/consumers.ts index ed0425e5..6b0265b3 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 inside its own body, `const answer = await (async () => { })();`, and returns a filtered value; 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 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.", }, }, deliveryFacts: ["implemented", "has-verifier"], @@ -1174,16 +1174,6 @@ export const consumersSpecs = [ intent: { outcome: "Capture the intent of the next body of work as Specs below `ready`, so planning sessions derive backlog and readiness from the graph and plan files point to their carrying Specs.", - openQuestions: [ - { - question: "Which Packs group the next bodies of work once each is commissioned?", - blocking: false, - key: "nextPacks", - decider: "owner", - provisional: - "a focused corpus-checks Pack holds corpus rules, architecture constraints and planned architecture; coverage and run evidence join the Spec Studio Pack only if they join its design", - }, - ], }, behavior: { rules: [ @@ -1294,16 +1284,19 @@ export const consumersSpecs = [ file: "specs/consumers/adopter-on-ramp.sdp.md", title: "An adopter learns graph homes, carrier grammar and shipped paths from the on-ramps", narrative: - "The help names the on-ramps by path because an operator can reach the CLI without a package runner and still needs to find the shipped on-ramps and the law they cite.", + "The help names the on-ramps by path because an operator can reach the CLI without a package runner and still needs to find the shipped on-ramps and the law they cite. A commit pin lets an adopter take any reviewed revision without a registry release, and the package builds itself during that install, so a re-pin is one dependency edit rather than a rebuilt vendored tarball.", sections: { intent: { problem: - "An adopter that follows the shipped on-ramps cannot learn where a deferral, an unsettled fact, a ruling awaiting its owner, a statement a test checks, or a count belongs, so it invents tags of its own, hand-kept registers, and scripts that keep them true.", + "An adopter needs a repeatable commit install and stable recipe inputs. An adopter that follows the shipped on-ramps cannot learn where a deferral, an unsettled fact, a ruling awaiting its owner, a statement a test checks, or a count belongs, so it invents tags of its own, hand-kept registers, and scripts that keep them true.", outcome: - "Teach each ruled home in the shipped skills, one line each with its carrying Spec, so an adopter states those truths in the graph and not beside it.", + "Teach commit installation and stable recipe inputs in the README. Teach each ruled home in the shipped skills, one line each with its carrying Spec, so an adopter states those truths in the graph and not beside it.", }, behavior: { rules: [ + "An adopter can pin the package to a git commit, and the package's `prepare` script builds the CLI, subpath exports and shipped recipes before installation completes. While the package version is `0.0.0`, the installation guidance uses a commit pin rather than an npm prerelease.", + "The shipped README teaches a commit install with npm and pnpm, the source-qualified build approval each needs, and a check that the installed CLI's `sdp --version` names the pinned full commit, for a git clone or a hosted git archive install.", + "The upgrade guidance teaches a recipe caller to pass the recipe's subject as `--params` data and run the shipped recipe file, never to rewrite the recipe body.", "The authoring skill teaches a deferral as a blocking open question whose `trigger:` field names what brings the Spec back, plus `dependsOn` for a true precondition. Its blocking question holds the Spec below `defined` without an adopter-specific tag. Example children may still state `defined` once their bound points are complete and match the parent's example vocabulary when it has one; their `ready` waits until the parent states at least `defined`.", "It teaches an unsettled fact as a constraint Spec whose blocking open question names the check that would settle it. Specs bounded by it declare `constrainedBy`; they may state `defined` when their own floor clears, and their `ready` waits until the constraint states at least `defined`.", "It teaches a ruling that awaits its owner as a decision Spec below `ready`, with `decidedBy` from each Spec the ruling shapes.", @@ -1314,7 +1307,10 @@ export const consumersSpecs = [ "The skills teach and never check. No validator reads an adopter's own tags, and an adopter's own policy checks stay the adopter's.", ], ruleKeys: { - helpNamesOnRamps: "rules[6]", + commitInstall: "rules[0]", + installGuidance: "rules[1]", + recipeUpgrade: "rules[2]", + helpNamesOnRamps: "rules[9]", }, }, }, @@ -1327,7 +1323,8 @@ export const consumersSpecs = [ readiness: "ready", file: "specs/consumers/agent-surface.register-recipes.sdp.md", title: "Register questions stay executable graph recipes", - narrative: null, + narrative: + "A corpus keeps registers of its own values, such as its limits, its error messages and its storage tables, as families of keyed Design entries whose keys share a prefix: `limit`, `error`, `table`. The key-family register lists one or more families across a scope each time it runs, so no one keeps those tables by hand. It reads Design and UI entries only. A rule or flow key names a law that an example exercises, and its text is a statement of law rather than a value two parties hold to. Rule coverage and entry search already list rule keys, the open-question register lists question keys, and a register that mixed laws with values would hide which rows are values.", sections: { intent: { problem: @@ -1345,9 +1342,23 @@ export const consumersSpecs = [ "Entry search gives an entry its address when one exists: `spec:#
    .` for a `design` or `ui` key matching `^[a-z][A-Za-z0-9]*$` other than `description`, `spec:#question.` for the text of an open question that carries a key, and `spec:#rule.` for a keyed rule or flow; every other entry has `address: null`.", "Whole tokens are maximal runs of Unicode letters and digits, split at camelCase humps and compared without case; a multiword term matches only as a consecutive run in the same order inside one key or text.", "Pinned declarations reports each keyed Design entry whose value opens with a code span, giving the Spec, the key, and the span content as authored with the count of entries and of Specs, and parses no language inside the span.", + "The key-family register lists every keyed Design and UI entry whose key starts with one of the given prefixes, across the corpus, the members of one Pack, or a list of Specs, each with its Spec, section, key, value as the graph holds it, file and line, with no row cap.", + "The key-family register never lists a rule, flow or question key, or the `description` entry.", "Each body composes the reader and adds no reader join or CLI verb.", "The recipe check executes each catalog body as written, and the on-ramps point to the catalog for its contents.", ], + ruleKeys: { + keyFamily: "rules[8]", + familySections: "rules[9]", + }, + }, + design: { + familyRecipe: + 'recipe 41, the key-family register, takes `params.prefixes`, a non-empty list of unique non-empty strings, each matched case-sensitively against the start of a key as authored, with `["finding", "impact"]` when absent, and at most one scope, `params.pack` or `params.specs`, reading the whole corpus with neither and a listed Spec once however often it is listed; a malformed prefix list, both scopes, or a Pack or Spec id that does not resolve returns `{ diagnostics: [{ parameter, problem }] }` and no register, `parameter` naming the parameter at fault and `specs` when both scopes are given. A Pack member that does not resolve returns a `pack` diagnostic naming that member, and no register.', + familyShape: + "`{ prefixes, scope, totals: { rows, specs, byPrefix }, rows }` is the register, where `scope` is `{ corpus: true }`, `{ pack }` or `{ specs }`, `totals.specs` counts the Specs with a row, and `byPrefix` maps each prefix, in the given order, to the count of rows it matches, a key that matches two prefixes counting under each.", + familyRow: + "`{ key, spec, section, value, file, line }` is one row, with `section` either `design` or `ui`, and `line` null when the location table records no line for the entry; rows sort by key, then Spec id, then section, in code-unit order.", }, }, deliveryFacts: ["implemented", "has-verifier"], @@ -1411,41 +1422,26 @@ export const consumersSpecs = [ id: "spec:consumers.spec-studio.components", specKind: "contract", altitude: "story", - readiness: "scoped", + readiness: "defined", title: "The Studio's custom elements each render one view in pages that load their script and supply graph data", - narrative: null, + narrative: + "The original's harness element has no subject in the graph, which holds example spaces, bound points and oracle bindings but no harness node, so its successor takes the Spec that owns an example space. No diagram renderer is chosen yet: the design that adds the first diagram names one within the custom-elements decision.", sections: { intent: { outcome: "Give the Studio a small set of custom elements that each render one view of the graph for a Studio page or an HTML report that can load the element script and supply its graph data.", - openQuestions: [ - { - question: - "The original embeds interactive LikeC4 diagrams through an element that wraps LikeC4's own runtime; a LikeC4 export is designed-for and deferred, and the shipped Mermaid projection draws bounded one-hop Spec and Pack diagrams and never the whole graph. Which renderer draws a Studio diagram, and does that bound hold for it?", - blocking: false, - key: "embeddedDiagrams", - decider: "owner", - provisional: - "a later phase picks a renderer compatible with `spec:decisions.studio-web-components`, or a decision that refines it admits a framework runtime", - }, - { - question: - "The original's harness element takes a harness id and keeps its coverage current as a person works it; the graph holds example spaces, bound points and oracle bindings, and has no harness node and no `harness:` namespace. What does the element take as its subject?", - blocking: false, - key: "harnessElement", - decider: "owner", - provisional: - "a later-phase element takes `spec-id` naming the Spec that owns the example space and renders that space's dimensions, its children's bound points and its expected-outcome oracle; coverage stays a report, never a verifier run or a delivery fact", - }, - ], }, behavior: { rules: [ "Each element is self-contained and light on dependencies, and works in an HTML page that can load its script and supply its graph data.", - "Each element reads the one data object its page sets, as `spec:consumers.spec-studio.data` states. An element with a graph subject takes that subject's graph id in an attribute named by its Design entry; an element over accumulated intents has no single graph subject, and a renderer-specific subject waits for its named design question.", + "An element that renders a view of the graph reads the one data object its page sets, as `spec:consumers.spec-studio.data` states. An element with a graph subject takes that subject's graph id in an attribute named by its Design entry; an element over accumulated intents has no single graph subject.", "Every element's name starts with `sdp-`.", + "A diagram is drawn by a renderer that runs as a custom element under `spec:decisions.studio-web-components`, or by a framework runtime that a decision refining it admits; the design that adds the first diagram names the renderer and the bound on what one diagram draws.", ], + ruleKeys: { + diagramRenderer: "rules[3]", + }, }, design: { description: "The elements in the original's order, with their attributes.", @@ -1455,10 +1451,10 @@ export const consumersSpecs = [ "`sdp-trace-graph` with `spec-id`, `depth` and `highlight`: the Spec's relations drawn as SVG to the given depth, highlighting what `highlight` names, such as Specs with no verifier binding.", maturityMap: "`sdp-maturity-map` with `pack-id` and `group-by`: a Pack's members by readiness, grouped by the named field of the member values, such as kind or stated rung.", - harness: - "an interactive element over an example space; its name, subject attribute and coverage behavior await `spec:consumers.spec-studio.components#question.harnessElement`.", + exampleSpace: + "`sdp-example-space` with `spec-id`, naming the Spec that owns the example space: the space's dimensions, its children's bound points and its expected-outcome oracle binding; its coverage is a report, never a verifier run or a delivery fact.", diagramView: - "a diagram element whose renderer, name and subject attribute await `spec:consumers.spec-studio.components#question.embeddedDiagrams`.", + "a diagram element whose name and subject attribute the design that adds it names, under the diagram-renderer rule.", scenarioEditor: "`sdp-scenario-editor` with `spec-id` and `mode`: a given, when and then editor for one example, which in `propose` mode composes intent for a new one.", impactView: "`sdp-impact-view` with `spec-id`: a Spec's impact panel on its own.", @@ -1475,43 +1471,42 @@ export const consumersSpecs = [ id: "spec:consumers.spec-studio.data", specKind: "contract", altitude: "story", - readiness: "scoped", - title: "The Studio hydrates from the serialized graph and the reader's values", - narrative: null, + readiness: "ready", + title: "The Studio renders from the reader's values and embeds only what its script reads", + narrative: + "The build writes each value into the page's markup, so a page reads in full as HTML. Search is the exception: a browser refuses a fetch from a page opened through `file://`, so the search page carries its index inside it. On the first adopter's corpus that index is too large to repeat on every page, which is why search runs on a page of its own.", sections: { intent: { outcome: - "Give every Studio page its data as files the build writes beside it, so the page reads the graph the agent surface reads and derives nothing of its own.", - openQuestions: [ - { - question: - "The original hydrates the Studio from AI slices beside the graph and the validation report; the context bundle is a catalog recipe composed on each invocation and never saved, so no Spec states a saved slice. Does the Studio's data hold a slice, and which Spec would state it?", - blocking: false, - key: "aiSlices", - decider: "owner", - }, - ], + "Render every Studio page from the values the reader returns for the graph the agent surface reads, so a page derives nothing of its own and carries only the data its script needs.", }, behavior: { rules: [ - "The Studio hydrates from data files under `generated/spec-studio/data/`, written by the build that writes its pages.", - "Each data file holds values the graph serializes or the reader returns, and the page follows `spec:consumers.spec-studio`'s rule against recomputing graph joins, claims, delivery facts, readiness and floor failures.", - "A page embeds the data it reads in one `script` element of type `application/json` with the id `sdp-graph`, parses it once into `window.__sdp__.graph`, and every element on the page reads from that one object.", + "The build renders the index from `specs()`, `packs()`, `findings()` and each Spec's design columns, a Pack page from `packContext` and the Pack totals, a Spec page from `specContext`, and the search page from the search index; every page's rail renders the rail values. A page states no value the reader did not return, apart from counts the build takes of those values.", + "A page whose script reads data embeds it in one `script` element of type `application/json` with the id `sdp-data`, serialized as JSON with every `<` then written as the six characters `\\u003c`, so no text can close the element. The search page embeds the search index there; every other page embeds no data, because its values are in its markup.", + "The page set writes no data file of its own: an agent reads the graph through `sdp q`, and `sdp build` writes the serialized graph.", + "The Studio's data holds no context bundle or other AI slice: the context bundle is composed on each invocation and never saved, and a design pass composes one with its recipe when it needs it.", ], + ruleKeys: { + readerValues: "rules[0]", + embeddedData: "rules[1]", + noDataFiles: "rules[2]", + noSlices: "rules[3]", + }, }, design: { - graphFile: - "the serialized graph exactly as `sdp build` writes `generated/graph.json`, with its `schemaVersion`.", - reportFile: "the validation report, the findings the reader returns from `findings()`.", - specContexts: - "for each Spec, the reader's `specContext` value whole: descriptors, narrative, sections, `designColumns`, stated and derived readiness, `floorFailures`, the stated next rung with `statedNextRungFailures`, the floor's `nextRungFailures`, relations both ways with their reason and exercises when present, implementations, references, verifiers, the oracle binding when present, `entryLocations` and findings.", - packContexts: - "for each Pack, the reader's `packContext` value whole: framing, model references, members in authored order with each member's stated next rung, design columns and bindings, verifier gaps, boundary and findings.", - searchIndex: - "for each node, the fields the reader's `findByConcept` matches: id, title, anchor label, Pack framing, narrative and section content, so a search on the page matches what the concept entry matches.", + packTotals: + "`{ members: number; unresolved: number; heldBelowNextRung: number; waitingForAuthor: number; entries: number; declarations: number; openQuestions: number; blockingQuestions: number; withImplementations: number; withReferences: number; restsOn: number; restedOnBy: number; verifiedBy: number; supersededBy: number }`, the totals recipe 29 returns for the Pack, each counted as that recipe counts it.", + railValues: + "`{ packs: { id: string; title?: string; members: { id: string; title?: string; statedReadiness?: string; ownsExampleSpace: boolean }[] }[]; findings: { errors: number; warnings: number } }`, the values every page's rail renders: each Pack in id order with its members in authored order, a member owning an example space when its Spec context carries one, and the validation report's findings counted by severity.", + searchIndex: "`{ records: SearchRecord[] }`, one record per graph node, in id order.", + searchRecord: + '`{ id: string; nodeType: "Primitive" | "Pack" | "Anchor" | "CodeNode"; page: string | null; title?: string; label?: string; framing?: string; narrative?: string; sections?: Record; file?: string; line?: number; specKind?: string; statedReadiness?: string; floorReached?: string | null; bindings: { edge: string; specId: string }[]; entries: SearchEntry[] }`: `title`, `label`, `framing`, `narrative` and `sections` exactly as the graph node holds them, the fields `findByConcept` reads; `page` the node\'s page path in the page set, null for an Anchor or a CodeNode; `file` and `line` as recorded; the kind and the two rungs on a Spec only, `floorReached` null when no rung derives; `bindings` the Specs an Anchor\'s or CodeNode\'s outgoing edges reach, with the edge type, and empty for any other node.', + searchEntry: + "`{ section: string; entry: string | null; key: string | null; address: string | null; line: number | null; text: string }`, one per value recipe 23's walk reaches in a Spec's narrative and sections, taken with no term: each string, and each value that is not a string under a key the author coins, as its JSON text; in that walk's order, with that recipe's entry path, coined key, address and line. A term matches an entry as that recipe matches it, a string by its key or text and any other value by its key only, so the entries a term matches are the rows recipe 23 returns for it.", }, }, - deliveryFacts: [], + deliveryFacts: ["implemented", "has-verifier"], file: "specs/consumers/spec-studio.data.sdp.md", }, { @@ -1520,33 +1515,12 @@ export const consumersSpecs = [ altitude: "story", readiness: "defined", title: "A composing panel on every page gathers scoped intent for an agent", - narrative: null, + narrative: + "The change-proposal contract requires the commit whose graph the person read, and a generated page set carries no commit, so a proposal's base comes from the build stamp when one is present and from the person otherwise.", sections: { intent: { outcome: "Let a person gather the changes they want while they read, each bounded by a Spec, its neighbors, a Pack or one open question, and hand them to an agent that edits source.", - openQuestions: [ - { - question: - "The change-proposal contract requires the commit whose graph the person read, and a generated page set carries no commit; where does the panel get the proposal's base, and does it ask the person for a reason?", - blocking: false, - key: "proposalBase", - decider: "owner", - links: ["spec:consumers.intent-composition.proposal"], - provisional: - "the base is the commit a present build stamp names, otherwise one the person supplies, and the person supplies one reason for the whole proposal; without a base and a reason the panel exports scoped intent as text, and the receiving agent writes the proposal against a known commit", - }, - { - question: - "The change-proposal contract scopes a change by Spec ids and entry addresses only, while the panel gathers intent bounded by a Pack or a Spec's neighbors; does a Pack or neighbors scope expand to its member or neighbor Spec ids at hand-off, or does a later proposal schema admit Pack scopes?", - blocking: false, - key: "packScope", - decider: "owner", - links: ["spec:consumers.intent-composition.proposal"], - provisional: - "the ready proposal contract stands; a Pack-scoped request expands to the member Spec ids read from that Pack at the proposal base, and a request that changes Pack membership or framing, or names an empty Pack, stays scoped-intent text", - }, - ], }, behavior: { rules: [ @@ -1556,12 +1530,18 @@ export const consumersSpecs = [ "Gathered intents persist in the browser's local storage, so refreshing a tab on a phone loses none.", "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, which the person copies as text or saves as a document. The page opens no pull request, because a static page holds no credentials; any pull request comes from the agent's ordinary source edit.", + "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 expands at hand-off to the member Spec ids that Pack holds at the proposal's base; a request that changes a Pack's membership or framing, or names an empty Pack, stays scoped-intent text, so the change-proposal contract keeps its Spec and entry scopes.", ], exampleSpace: { given: ["the composing panel holds {gathered:number} gathered intents"], when: ["the person refreshes the tab"], then: ["the composing panel holds {kept:number} gathered intents"], }, + ruleKeys: { + proposalBase: "rules[6]", + packScope: "rules[7]", + }, }, ui: { gatheredIntents: @@ -1578,66 +1558,48 @@ export const consumersSpecs = [ id: "spec:consumers.spec-studio.lenses", specKind: "behavior", altitude: "story", - readiness: "scoped", + readiness: "defined", title: "Four lenses read the graph as Packs, architecture, tests and evidence", - narrative: null, + narrative: + "A capability is read as a Pack, or as a projection over high-altitude behavior Specs that no view draws yet, so the Packs lens groups by Pack and draws no capability tree. A verifier binding states that a verifier exists, and pass and fail stay in CI, so the tests lens shows bindings and no run result.", sections: { intent: { outcome: "Let a person see the graph four ways, by Pack, by architecture, by verification and by evidence, from one top navigation.", openQuestions: [ - { - question: - "A capability is a projection over high-altitude behavior Specs or a Pack grouping, and an arc's Pack is named for the capability it designs. Which grouping does the capability tree use, and which graph values supply it?", - blocking: false, - key: "capabilityGrouping", - decider: "owner", - provisional: "the Packs lens groups Specs by Pack, and no capability tree is drawn", - }, { question: "The original's architecture lens draws runtime layers, ports and external systems beside components and their dependencies; `spec:decisions.architectural-annotation` gives a component anchor a layer and a bounded context and any code anchor a role, beside component membership and `uses`, and admits no port or external-system node. Does the lens group components by layer and context and draw only what those anchors hold?", blocking: false, key: "architectureVocabulary", links: ["spec:consumers.structural-mermaid#question.layerAndContext"], - }, - { - question: - "The original's tests lens shows each test's last run result; a verifier binding states that a verifier exists, and pass and fail stay in CI, as `spec:decisions.binding-not-liveness` rules. Does the lens show run results read from CI output, or bindings only?", - blocking: false, - key: "testRunResults", - decider: "owner", - links: ["spec:observation.run-evidence"], - provisional: - "The first Studio build shows bindings only; admitting run records in a later phase awaits an explicit input and reproducibility ruling.", - }, - { - question: - "The original's tests lens names the rules of a Spec that no test exercises; a rule may carry a key, an example's `verifies` may name the keys it exercises, and recipe 34 lists each keyed rule with its examples, while the graph cannot tell which examples exercise a rule with no key. Does the lens show keyed-rule coverage with the unkeyed rules counted apart, or name the uncovered Specs only?", - blocking: false, - key: "ruleCoverage", decider: "owner", - links: ["spec:model.rule-keys"], provisional: - "the tests lens lists each keyed rule with the examples recipe 34 reports and counts the unkeyed rules apart", + "the lens groups components by their recorded layer and context and draws only component membership and `uses` edges, with no port or external-system node", }, ], }, behavior: { rules: [ "The Packs lens is the default landing page.", + "The Packs lens groups Specs by Pack and draws no capability tree.", "Clicking a component in the architecture lens opens its detail.", + "The tests lens shows verifier bindings and no run result; run records join it only through `spec:observation.run-evidence`, under an input and reproducibility ruling of their own.", "The evidence lens is read-only.", ], + ruleKeys: { + packGrouping: "rules[1]", + bindingsOnly: "rules[3]", + }, }, ui: { description: "The lenses in the order of the top navigation.", packsLens: - "a Spec-by-Spec tree grouped by Pack, with each Pack's members in authored order; any capability grouping awaits `spec:consumers.spec-studio.lenses#question.capabilityGrouping`.", + "a Spec-by-Spec tree grouped by Pack, with each Pack's members in authored order, which the index page holds.", architectureLens: - "the graph drawn as components and their dependencies, from component membership and `uses` edges, in an embedded interactive diagram whose renderer is the open question `spec:consumers.spec-studio.components#question.embeddedDiagrams`.", + "the graph drawn as components and their dependencies, from component membership and `uses` edges, in an embedded interactive diagram whose renderer follows `spec:consumers.spec-studio.components#rule.diagramRenderer`.", testsLens: - "a coverage view: for each Spec, its verifiers with their bindings, and the rules no example exercises.", + "a coverage view: for each Spec, its verifiers with their bindings, then each keyed rule with the examples recipe 34 reports as exercising it, with the unkeyed rules counted apart, because the graph cannot tell which examples exercise a rule with no key.", evidenceLens: "the graph-recorded evidence fields and absence labels `spec:consumers.spec-studio.verification-panels` states; while the graph records no observation or other evidence, each reads not tracked.", }, @@ -1649,31 +1611,15 @@ export const consumersSpecs = [ id: "spec:consumers.spec-studio.responsive", specKind: "behavior", altitude: "story", - readiness: "scoped", + readiness: "defined", title: "The Studio reads on a phone as on a desktop", - narrative: null, + narrative: + "The curated graph's edges carry no confidence, and the impact graph's inferred imports stay outside it, so a compact diagram shortens by distance alone.", sections: { intent: { actor: "A person reviewing a design on a phone, including a pull request reviewer who holds only a link.", outcome: "Let a design be reviewed on a phone through the same pages a desktop shows.", - openQuestions: [ - { - question: - "The curated graph's edges carry no confidence, and the impact graph's file-level inferred imports stay outside it; which details does a compact diagram presentation hide?", - blocking: false, - key: "summaryMode", - decider: "owner", - provisional: "a compact diagram drops relations past one hop", - }, - { - question: "Which exact CSS viewport width switches the layout to one column?", - blocking: false, - key: "phoneBreakpoint", - decider: "owner", - provisional: "720 CSS pixels", - }, - ], }, behavior: { rules: [ @@ -1687,10 +1633,10 @@ export const consumersSpecs = [ }, ui: { singleColumn: - "every layout collapses to one column below the breakpoint settled by `spec:consumers.spec-studio.responsive#question.phoneBreakpoint`.", + "every layout collapses to one column below 720 CSS pixels of viewport width.", pinchZoom: "on a touch device, every diagram zooms with a pinch.", summaryMode: - "a compact diagram presentation for narrow screens, whose contents await `spec:consumers.spec-studio.responsive#question.summaryMode`; it assigns no confidence to an edge.", + "a compact diagram presentation for narrow screens that drops relations past one hop and assigns no confidence to an edge.", }, }, deliveryFacts: [], @@ -1711,75 +1657,30 @@ export const consumersSpecs = [ "Give a person reviewing a design one generated HTML workbench that explores the whole graph through lenses, shows each Spec and Pack in context, and turns a wanted change into scoped intent rather than an edit.", problem: "The generated Markdown Design Review offers no built-in search or filters, and navigation is through its links, so reviewers in the first adopter open carrier files alongside it.", - openQuestions: [ - { - question: - "No Spec states phase 0's deliverables: the index, the Pack page, search and the `sdp studio` publisher with its flags, output root, invalidation, clean check and exit code. Which child Specs carry them, and do pull request previews join phase 0?", - blocking: false, - key: "phaseZeroSplit", - decider: "owner", - provisional: - "child Specs for the index, the Pack page, search and the publisher; the publisher's draft: `sdp studio [root] [--exclude PATH]... [--check-clean]` writes only `generated/spec-studio/` under the root, which defaults to the working directory, with each exclusion a root-relative path prefix; a run writes its page set to `generated/spec-studio.tmp/` and replaces the live root with it wholesale; every `sdp build` attempt invalidates the live and temporary Studio roots before extraction, and an unsuccessful Studio run removes any Studio root it cannot certify; under `--check-clean` it compares an independent twin with the current output, returns nonzero and removes divergent, missing or drifted output, and replaces certified output wholesale; it shows validation findings without withholding publication and returns the validation exit code; previews stay a later phase", - }, - { - question: - "The Studio is built as a single-page application, while its data contract both writes data files, which a page opened through `file://` cannot fetch, and embeds each page's data; is the Studio one page or a static page set, and how does each page get its data with no request?", - blocking: false, - key: "pageModel", - decider: "owner", - links: ["spec:consumers.spec-studio.data"], - provisional: - "a static page set of an index page and one page per Pack and per Spec, each embedding the data it reads in one JSON script element, with its local assets; a standalone data file, if any, duplicates the embedded values for consumers outside the page", - }, - { - question: - "The Studio shows only values the reader computes, while its pages need answers only catalog recipes compute, such as pinned declarations and keyed-rule coverage; may the Studio's build compose recipe answers over the same graph, and which?", - blocking: false, - key: "recipeValues", - decider: "owner", - links: ["spec:consumers.spec-studio.data"], - provisional: - "the build composes a recipe's answer over the same graph only when the Studio's design names that recipe, as recipe 24's pinned declarations and recipe 34's keyed-rule coverage; the page recomputes no join, and no context bundle is saved as Studio data", - }, - { - question: - "The first build's acceptance names one adopter's corpus, a register id and a path in another repository; does phase 0 keep those five named tasks, or state a measure any corpus can run?", - blocking: false, - key: "acceptanceMeasure", - decider: "owner", - provisional: "keep the first adopter's five named tasks until phase 0 is accepted", - }, - ], }, behavior: { rules: [ "The read-only Studio ships in the open-source package `@libar-dev/software-delivery-protocol`, beside the four shipped projections; hosted review, shared comments and hosted previews may later ship under `@libar-ai/`.", "The Studio is a projection of the one graph: its page set under `generated/spec-studio/` is regenerated from the graph, states no truth, and confers nothing back into the graph.", - "The Studio is a mostly static single-page application, buildable as an `index.html` page and its assets, and it needs no server.", + "The Studio is a static page set: an index page, a search page, and one page per Pack and per Spec, with their local assets. Its build renders each page whole, so a page needs no server, and every page but the search page reads without its script, which adds filtering, search and copying.", "The Studio works offline: the same files open from the file system through `file://` for local use and from any static host for sharing.", "The Studio is reproducible: one graph renders byte-identical files, and an asset whose filename embeds a hash of its content gets the same name from the same graph.", "The Studio is read-rich: everything the graph holds about a Spec or a Pack is explorable in it.", "The Studio is write-careful: it writes nothing to canonical source, and a change a person wants leaves it as scoped intent for an agent, as `spec:consumers.edit-model` states.", "The Studio never builds a second graph: every join, claim, delivery fact, derived readiness, floor failure and finding it shows is a value the reader computed when the Studio was built, and the page computes none of them again.", + "A page shows a catalog recipe's answer only when the Studio's design names that recipe, and the build composes the answer over the graph it derived; the page recomputes none of it.", "The Studio stands beside the shipped Design Review, census, Mermaid and Gherkin projections and re-specifies none of them, which `spec:decisions.shipped-projections-frozen` keeps as they are.", ], - exampleSpace: { - given: ["an extraction root whose graph holds a Pack and its member Specs"], - when: ["the Studio renders twice from freshly derived graphs of that root"], - then: [ - "the page set holds the entry page {entryPage:string}", - "the two renders are byte-identical: {byteIdentical:boolean}", - "every carrier and source file under the root stays byte-identical: {rootUntouched:boolean}", - ], - }, ruleKeys: { + staticPageSet: "rules[2]", noSecondGraph: "rules[7]", + recipeAnswers: "rules[8]", }, }, verification: { mode: "manual", criteria: [ - "The Studio's first build is accepted when a person completes, from the Studio's page set and the files it links to, the five tasks the first adopter set over its history-view Pack (`libar-platform/docs/feedback/sdp-feedback-01.md`): find the history contract, locate OD-157, explain the aggregate dependency, find one declaration's type comparison, and say whether a paused-rebuild example has native-backend evidence. The first build reads no run evidence, so the last task passes when the person finds that the example requires the native backend tier and has a bound verifier, and reports that no run result is supplied.", + "The Studio's first build is accepted when a person completes, from the Studio's page set and the files it links to, the five tasks the first adopter set over its history-view Pack (`libar-platform/docs/feedback/sdp-feedback-01.md`): find the history contract, locate OD-157, explain the aggregate dependency, find one declaration's type comparison, and say whether a paused-rebuild example has native-backend evidence. The first build reads no run evidence, so the last task passes when the person finds that the example requires the native backend tier, reads whether a verifier is bound to it, and reports that no run result is supplied.", ], }, }, @@ -1797,18 +1698,6 @@ export const consumersSpecs = [ intent: { outcome: "Let a stakeholder open the Studio of a pull request from a link, and let its author know which graph and which build the stakeholder read.", - openQuestions: [ - { - question: - "How does the shell read the publishing stamp while generated pages stay a function of the graph and a page opened through `file://` makes no request, which keys does the stamp hold, and which step publishes a preview?", - blocking: false, - key: "stampLoading", - decider: "owner", - links: ["spec:consumers.spec-studio#question.phaseZeroSplit"], - provisional: - "the shell reads `stamp.json` only when served over http or https, so a `file://` page makes no stamp request; its keys are `schemaVersion`, `corpusCommit`, `engine` and `builtAt`; publishing a preview and writing its stamp are the adopter's step, and the Studio build writes only its graph-derived page set", - }, - ], }, behavior: { rules: [ @@ -1816,11 +1705,15 @@ export const consumersSpecs = [ "Each pull request can publish its Studio as a preview.", "A stakeholder opens the link, navigates the Studio and composes intent, and the pull request's author hands that intent to an agent that edits the source.", "The Studio's build writes no stamp, so every page and data file it writes stays a function of the graph alone. The publishing step that uploads a build writes one stamp file beside the page set, and the shell shows it as build context when it is present.", + "Publishing a preview and writing its stamp are a step the adopter runs, never the Studio's build; the shell reads the stamp as `spec:consumers.spec-studio.shell#rule.stamp` states.", ], + ruleKeys: { + publishingStep: "rules[4]", + }, }, design: { buildStamp: - "`stamp.json` beside the page set, written by the publishing step and never by the Studio's build, holding the graph's `schemaVersion`, the git commit the build came from or `unknown` for a local build, the engine `sdp --version` names, and the build time.", + "`{ schemaVersion: string; corpusCommit: string; engine: string; builtAt: string }` in `stamp.json` beside the page set, written by the publishing step and never by the Studio's build: the graph's `schemaVersion`, the git commit the build came from or `unknown` for a local build, the engine `sdp --version` names, and the build time as an ISO 8601 UTC timestamp.", previewPath: "a pull request's preview is served at `/preview//`.", }, }, @@ -1831,168 +1724,107 @@ export const consumersSpecs = [ id: "spec:consumers.spec-studio.shell", specKind: "behavior", altitude: "story", - readiness: "scoped", + readiness: "ready", title: "The Studio's shell frames every page with navigation, a rail and a canvas", - narrative: null, + narrative: + "The shell carries what every page shares, including the rules that keep a page readable: by keyboard, without color, on a phone and without its script. Recent edits stay out of it, because a page is a function of the graph alone and git holds the history.", sections: { intent: { outcome: - "Let a person move between lenses, Packs and Specs from any page, and find a Spec by what they remember of it.", - openQuestions: [ - { - question: - "The original's left rail shows the current Pack's tree, its specs and then its harnesses; the graph has no harness node. Does the rail list harnesses, and from which graph value?", - blocking: false, - key: "railHarnesses", - decider: "owner", - provisional: - "the rail lists the current Pack's members that own an example space, each linked to its Spec page, and creates no harness identity", - }, - { - question: - "The original's left rail lists recent edits; the graph carries only current state, git is the event log, and the shipped views are a function of the graph alone. Does the rail read recent edits from git when the Studio is built, or leave them out?", - blocking: false, - key: "recentEdits", - decider: "owner", - provisional: - "leave recent edits out, so a Studio page stays a function of the graph alone", - }, - { - question: - "The original's search indexes tags; the Spec envelope is closed to `id`, `kind`, `altitude`, `readiness` and `relations` with the H1 as title, and no free-form tag vocabulary is admitted. What, if anything, does search index in place of tags?", - blocking: false, - key: "searchTags", - decider: "owner", - provisional: - "search matches what `findByConcept` matches, and indexing any further value needs the phase-0 search Spec's own contract", - }, - ], + "Let a person move between the index, Packs and Specs from any page, find a Spec by what they remember of it, and read every page by keyboard, on a phone and without its script.", }, behavior: { rules: [ - "Every page has the same shell: a top navigation, a left rail and a main canvas.", - "Search is persistent on every page and uses the searchIndex data entry's scope; each result identifies its node type. A Spec result shows its stated rung beside the floor reached and opens its page; a Pack result opens its page without a readiness label; an Anchor or CodeNode result shows its recorded source location.", + "Every page has the same shell: a top navigation, a left rail and a main canvas, and it opens with a link that skips to the canvas.", + "A search field is on every page; `spec:consumers.spec-studio.search` states what it matches and how its results show.", "The rail's count of findings is the count of the validation report's findings, never a count the page derives.", + "The shell shows no recent edits and no other history: git holds it.", + "Served over http or https, the shell reads `stamp.json` beside the page set and, when it loads, shows the build context it names; a page opened through `file://` requests nothing and shows no stamp.", + "A stated rung shows as a badge that carries the rung's word, colored from a palette of four that stays distinct under color blindness; the floor reached shows beside it as words with no color of its own, and only the divergence banner adds a warning color, beside its words.", + "Every status a color marks is also written in words, so no reading depends on color.", + "Every link and control is reachable from the keyboard in reading order and shows where the focus is.", + "Prose runs no wider than about 80 characters, and a table wider than the canvas scrolls inside its own box, never the page.", + "Authored text renders as plain text with each inline code span as code, and no other Markdown is read. A mention the prose-mentions validator reads links to its target's page when the target resolves, and to the entry when the mention is an entry address.", + "The index, every Pack page and every Spec page read in full without their script: their collapsible parts are native disclosure elements, and only filtering, copying and the stamp need the script. The search page needs its script to match, and without it says so and links to the index.", ], + ruleKeys: { + noRecentEdits: "rules[3]", + stamp: "rules[4]", + rungBadge: "rules[5]", + wordsBesideColor: "rules[6]", + keyboard: "rules[7]", + lineLength: "rules[8]", + text: "rules[9]", + withoutScript: "rules[10]", + }, }, ui: { description: "The parts of the shell, top to bottom and left to right.", topNavigation: - "Packs, Architecture, Tests, Evidence and Search; a capability grouping awaits `spec:consumers.spec-studio.lenses#question.capabilityGrouping`.", + "the Studio's name linking to the index, then Packs, linking to the index's Packs section, then the search field; each lens page the page set holds adds its entry in the order `spec:consumers.spec-studio.lenses` gives.", + stampLine: + "at the end of the top navigation, the stamp's corpus commit, engine and build time, when the stamp loads.", leftRail: - "the current Pack's tree, its member Specs in authored order and the validation report's finding counts by severity; recent edits await `spec:consumers.spec-studio.shell#question.recentEdits`.", + "on a Pack page, the Pack's members in authored order, each linked to its card on the page; on a Spec page, each Pack the Spec belongs to with its members in authored order, each linked to its Spec page and the current one marked; on any other page, every Pack; then, on a Pack or Spec page, under an `Example spaces` heading shown only when there is one, each member of the rail's Packs that owns an example space, linked to its Spec page, with no harness named; then the validation report's finding counts by severity, linked to the index's findings.", + railOnPhone: + "below the phone breakpoint the rail follows the canvas, so a page opens on its content.", mainCanvas: "the page of the Spec or Pack selected in the rail or in a lens.", - search: - "matches the fields `spec:consumers.spec-studio.data#design.searchIndex` lists, with the same matching and ordering as `findByConcept`; results follow the search rule.", }, }, - deliveryFacts: [], + deliveryFacts: ["implemented", "has-verifier"], file: "specs/consumers/spec-studio.shell.sdp.md", }, { id: "spec:consumers.spec-studio.spec-page", specKind: "behavior", altitude: "story", - readiness: "scoped", + readiness: "defined", title: "A Spec page shows one Spec in collapsible panels, in a fixed order", narrative: - "The original runtime panel linked Fastify routes to Effect programs, showed what Effect layers provided and required, and recorded Awilix registrations and their lifetimes, beside ports and external systems. That representation is lineage evidence in `docs/lineage/v0-design/07-spec-studio-and-projections.md`, section 2.3, Section F. The current structural anchors record identity, component membership, uses, role, and a component's architectural layer and context; the question below asks whether a later runtime panel needs more.", + "The original runtime panel linked Fastify routes to Effect programs, showed what Effect layers provided and required, and recorded Awilix registrations and their lifetimes, beside ports and external systems. That representation is lineage evidence in `docs/lineage/v0-design/07-spec-studio-and-projections.md`, section 2.3, Section F. The current structural anchors record identity, component membership, uses, role, and a component's architectural layer and context; the question below asks whether a later runtime panel needs more.\n\nThe page shows only what the graph holds. The original header's owner, tags and last-verified time, its schema bindings and its `UI / Stories` section have no graph value behind them, so the page shows none of them.", sections: { intent: { outcome: "Show everything the graph holds about one Spec on one page, in panels a person opens and closes one at a time.", openQuestions: [ - { - question: - "The original header shows an owner, a capability, tags and a last-verified time; the envelope is closed to `id`, `kind`, `altitude`, `readiness` and `relations` with the H1 as title, a capability is a projection or a Pack, and a verifier binding never records when a test last ran. Which of the four does the header show, and from which graph value?", - blocking: false, - key: "headerFields", - decider: "owner", - }, - { - question: - "The original pins one color for each of seven rungs: light grey for `sketch`, blue for `framed`, cyan for `specified`, green for `designed`, emerald for `bound`, indigo for `executable` and violet for `verified`; readiness has four rungs, idea through ready, and the floor reached renders beside the stated rung. Does the palette color the stated rung, the floor reached, or both?", - blocking: false, - key: "rungColors", - decider: "owner", - }, - { - question: - "The original shows each open question's owner and the date it was added; an open question carries its text, whether it blocks, an optional key and the optional fields `decider`, `links`, `provisional` and `trigger`, where `decider` names who settles it as a corpus token rather than a person, and git, not the graph, records when its line was added. Which fields does the panel show, and does a date stay off a page that is a function of the graph?", - blocking: false, - key: "questionMetadata", - decider: "owner", - links: ["spec:model.open-question-fields"], - provisional: - "the panel shows every field the graph supplies, as the intent panel states, and no date", - }, { question: "Does the runtime panel show more than the structural neighborhood of the Spec's implementation anchors?", blocking: false, key: "runtimeComposition", - }, - { - question: - "The original's bindings panel lists schema bindings beside code and test bindings; anchors bind implementation code, tests and oracles, and no anchor binds a schema. Does a schema binding need a home?", - blocking: false, - key: "schemaBindings", - }, - { - question: - "The original's page outline names a section `UI / Stories`, whose UI half the UI panel carries; the original never says what a story is. What is a story, and does the page show one?", - blocking: false, - key: "uiStories", - }, - { - question: - "Panels name code units, components and anchors, and the architecture lens selects a component, yet no Studio page or data value designs a destination for them. Does each get a page, or show its id and recorded repository-relative file and line as text?", - blocking: true, - key: "sourceLinks", decider: "owner", provisional: - "no code-unit pages in phase 0; a code unit or anchor shows its id and recorded repository-relative file and line as text, with an action that copies file:line and no editor URI, absolute path or assumed source URL; source navigation waits for a separately designed source target, and a component's attributes, members and uses show in the architecture lens", + "the panel shows only the structural neighborhood the anchors record, until a reader asks for more of the runtime composition", }, ], }, behavior: { rules: [ "Each panel collapses and expands on its own.", - "The header renders readiness as `spec:consumers.derived-readiness-banner` states: the stated rung beside the floor reached, with the divergence banner only in the dishonest direction.", - "The bindings panel speaks binding language as `spec:consumers.binding-language-views` states: each binding present or none, and the runtime observation not tracked.", - "Every Spec and Pack a panel names links to its page; how a code unit or anchor is reached awaits `spec:consumers.spec-studio.spec-page#question.sourceLinks`.", - "The design panel lists the `design` entries in authored order with their keys as code, and the UI panel lists the `ui` entries the same way, as `spec:decisions.authored-entry-order` rules for the Design Review.", + "The page's core, its header, sections, relations, bindings and findings, is the one `spec:consumers.spec-studio.spec-page.core` states, and the panels below add to it.", + "Every Spec and Pack a panel names links to its page. A code unit, component or anchor shows its id and its recorded repository-relative file and line as text, with an action that copies `file:line`; the page set holds no code-unit page and writes no editor URI, absolute path or assumed source URL, source navigation waits for a source target designed for it, and a component's attributes, members and uses show in the architecture lens.", ], + ruleKeys: { + sourceText: "rules[2]", + }, }, ui: { - description: "The panels in page order.", - header: - "the Spec's id, its stated readiness beside the floor reached, its stated next rung with the clauses that keep it below that rung, its altitude and kind; its title; its design columns; the Packs it belongs to; and its warning and error counts as pill badges; the header infers no promotion.", - readinessColors: - "a palette distinct under color blindness, with text beside every color; whether it colors the stated rung, floor reached or both awaits `spec:consumers.spec-studio.spec-page#question.rungColors`.", - intentPanel: - "the intent section as prose, then an open-questions panel headed with its count; each question shows its text, blocking flag, key and address when keyed, its decider, links, provisional reading and trigger when present, and its carrier line when recorded, with an action that composes intent scoped to it.", + description: "The panels the core does not hold, in page order.", + intentPanel: "beside each open question, an action that composes intent scoped to it.", behaviorPanel: - "rules and flows in authored order, a keyed entry with its rule address and the examples that exercise it as recipe 34 reports, followed by example cards laid out as `spec:consumers.spec-studio.verification-panels` states.", + "beside each keyed rule or flow, the examples that exercise it as recipe 34 reports, then example cards laid out as `spec:consumers.spec-studio.verification-panels` states.", constraintsPanel: "each constraint with its target, laid out as `spec:consumers.spec-studio.verification-panels` states.", designPanel: - "a diagram of the components that realize the Spec, from its implementation anchors' `component` and `uses` edges, drawn as SVG whose nodes show their id, source file and line, with their destination awaiting `spec:consumers.spec-studio.spec-page#question.sourceLinks`; below it, the `design` entries, then the decision records the Spec names by `decidedBy` with each decision's text inline.", + "a diagram of the components that realize the Spec, from its implementation anchors' `component` and `uses` edges, drawn as SVG whose nodes show their id, source file and line as the source-text rule states; below the design entries, the decision records the Spec names by `decidedBy`, each with its text inline.", runtimePanel: "the structural neighborhood of the Spec's implementation anchors: each anchor's component and the code units it uses, with file and line.", - bindingsPanel: - "implementation bindings, design references, verifier bindings and the expected-outcome oracle, each with its id and recorded repository-relative file and line, shown as text with an action that copies file:line, plus runtime observation reading not tracked; source navigation awaits `spec:consumers.spec-studio.spec-page#question.sourceLinks`.", verificationPanel: "the verifiers and the coverage of the example space, laid out as `spec:consumers.spec-studio.verification-panels` states.", evidencePanel: "the evidence fields and absence labels `spec:consumers.spec-studio.verification-panels` states, with no invented build, deployment, measurement or SBOM data.", impactPanel: "the upstream parents and downstream children, the Specs that depend on this one, the decision records it names, the tests that verify it, and the code its bindings reach.", - uiPanel: "the `ui` entries.", - relationsPanel: - "relations both ways, each with its type, claim, endpoint, resolution, reason and exercises when present; a resolving Spec endpoint links to its page.", - entryIds: - "every addressable keyed Design or UI entry, every keyed entry of the primary behavior owner and every keyed open question carries an HTML element id equal to its entry address suffix, the section and lower-camel key without the Spec id or `#`, so a link opens the entry on its page.", }, }, deliveryFacts: [], @@ -2004,52 +1836,23 @@ export const consumersSpecs = [ altitude: "story", readiness: "scoped", title: "The panels for examples, targets, verifiers and evidence", - narrative: null, + narrative: + "The graph records that a verifier binding exists and never a run, and a static page runs nothing, so the panels show bindings and no run result, and offer no run of an example. No Spec designs a source for build attestations, deployments or SBOMs, so each reads not tracked.", sections: { intent: { outcome: "Show on a Spec page how far each example, constraint target and verifier goes, and what runtime evidence exists, without stating more than the graph records.", - openQuestions: [ - { - question: - "The original shows each test's runner, whether it passed, its duration and its last run, and offers to run an example again; the graph records that a verifier binding exists and never a run, and a static page runs nothing. Does a panel show run results read from CI output when the Studio is built, or bindings only?", - blocking: false, - key: "runResults", - decider: "owner", - links: ["spec:observation.run-evidence"], - provisional: - "The first Studio build shows bindings only; admitting run records in a later phase awaits an explicit input and reproducibility ruling.", - }, - { - question: - "Is per-slot coverage sufficient for the Studio grid, or does it need the combinations still open in spec:consumers.agent-surface.example-space-coverage#question.combinationCoverage?", - blocking: false, - key: "coverageSource", - decider: "owner", - links: [ - "spec:consumers.agent-surface.example-space-coverage#question.combinationCoverage", - ], - provisional: - "per-slot coverage for the grid; combinations wait for an adopter's design pass", - }, - { - question: - "The original's evidence panel shows the last build with its SLSA attestation, the last deployment and a CycloneDX SBOM; no Spec designs a source for any of them. Does the Studio show them, and from which input?", - blocking: false, - key: "deliveryArtifacts", - decider: "owner", - provisional: "each reads not tracked until a Spec designs its source", - }, - ], }, behavior: { rules: [ "An example card shows the example's verifier as a binding that exists, and the example as an Executable Spec when a test anchor binds it, never as a test that passed.", + "A panel shows verifier bindings and no run result; run records join a panel only through `spec:observation.run-evidence`, under an input and reproducibility ruling of their own.", "The evidence panel shows only runtime evidence the graph records, and reads not tracked while no delivery fact records an observation.", "Each panel action gathers intent through the composing panel's contract.", ], ruleKeys: { - evidenceNotTracked: "rules[1]", + bindingsOnly: "rules[1]", + evidenceNotTracked: "rules[2]", }, }, ui: { @@ -2065,11 +1868,11 @@ export const consumersSpecs = [ unsetTarget: "a constraint with no quantitative target says so, and offers to compose intent proposing one.", verifierList: - "each verifier with its id, its binding, and its recorded repository-relative file and line; no line is invented for a verifier the graph does not locate, and source navigation awaits `spec:consumers.spec-studio.spec-page#question.sourceLinks`.", + "each verifier with its id, its binding, and its recorded repository-relative file and line as text, as `spec:consumers.spec-studio.spec-page#rule.sourceText` states; no line is invented for a verifier the graph does not locate.", coverageGrid: - "when the Spec owns an example space, the coverage `spec:consumers.agent-surface.example-space-coverage` reports, listing each literal value of each slot with the examples that bind it and listing a non-literal-union slot as not enumerable; an action composes intent proposing examples for missing values.", + "when the Spec owns an example space, the per-slot coverage `spec:consumers.agent-surface.example-space-coverage` reports, listing each literal value of each slot with the examples that bind it and listing a non-literal-union slot as not enumerable; combinations of slots join the grid only when a design pass asks for them; an action composes intent proposing examples for missing values.", evidencePanel: - "runtime observation reads not tracked under the binding-language rule; build attestations, deployments and SBOMs show no payload while the graph has no designed record for them, and each reads not tracked instead.", + "runtime observation reads not tracked under the binding-language rule; build attestations, deployments and SBOMs show no payload and each reads not tracked until a Spec designs its source.", }, }, deliveryFacts: [], @@ -2140,7 +1943,8 @@ export const consumersSpecs = [ readiness: "ready", file: "specs/consumers/agent-surface.recipe-parameters.sdp.md", title: "Recipes take parameters as data and ship as runnable files", - narrative: null, + narrative: + "A body reads its parameters by name, so a name it does not read would be dropped without a word, and a recipe with a sample would answer for the sample: a misspelled name would look like a successful call. A recipe therefore answers a name it does not take with that name and the names it does take, and nothing else. A body cannot import and the reader holds no recipe metadata, so each body declares its own names in its first statement, and the recipe check holds that declaration to the catalog.", sections: { intent: { problem: @@ -2154,8 +1958,22 @@ export const consumersSpecs = [ "`--params` is repeatable in three forms: a JSON object, `@PATH` naming a file that holds one, and `KEY=@PATH` binding the JSON value in the file, of any type, to `params.KEY`, with `KEY` on the lower-camel grammar. Objects merge at the top level from left to right, and a key given twice by any form is refused before the body runs, with exit 1, one line on stderr and no output on stdout, so a recipe takes a large file such as a base graph beside its other parameters.", "The bindings `g`, `graph` and `report` keep their names and meaning beside `params`. The sink evaluates one operator-supplied body and adds no query vocabulary: a parameter is input to a body, never a verb.", "Every recipe reads its parameters under the names the catalog states. A parameter with a documented sample falls back to it when absent, so each body runs as written; a required input with no sample, such as a base graph, a proposal or an imports file, returns diagnostics when absent. Recipe 4 reads its changed files from `params.files`.", + "Each catalog body opens by declaring the parameter names it takes, in the order the catalog's index lists them, and checks `params` against that list before it reads anything else; a recipe that takes no parameter declares an empty list.", + "A recipe given a parameter name it does not take returns `{ unknownParams, accepts }` as its whole answer, and `sdp q` exits 0; it never answers for its sample, for an empty input or for the names it does take in that case.", + "The recipe check holds each body's declared names to the catalog's index and to the `params` names the body reads, and runs each body with a name it does not take.", "The package build writes each catalog body to `dist/recipes/-.js`, where `NN` is the two-digit recipe number and the slug is the recipe's heading in lower kebab case, and the package ships that directory. The catalog stays the one owner of the bodies; the files are derived, and the recipe test checks that each file equals its catalog body byte for byte.", ], + ruleKeys: { + declaredNames: "rules[4]", + unknownNames: "rules[5]", + namesChecked: "rules[6]", + }, + }, + design: { + acceptsPreamble: + "`const accepts = [...]; const unknownParams = Object.keys(params).filter((name) => !accepts.includes(name)); if (unknownParams.length > 0) return { unknownParams, accepts };` opens every catalog body as three statements on three lines, with the recipe's names in the brackets as double-quoted strings.", + unknownAnswer: + "`{ unknownParams, accepts }` is the whole answer for a name a recipe does not take, where `unknownParams` lists every such name in the order `params` holds them, and `accepts` repeats the recipe's declared names.", }, }, deliveryFacts: ["implemented", "has-verifier"], @@ -2207,9 +2025,14 @@ export const consumersSpecs = [ rules: [ "`sdp --version` prints one line, `sdp ()`, and exits 0. The version is the package's own version; the commit is the full hash the build was made from, or `unknown` when the build could not read one; a build from a checkout with uncommitted changes names the commit checked out when it ran.", "The build records the commit in a file beside the compiled CLI, and the CLI reads that file; it never runs git at run time.", + "The build reads the commit from git first. When git gives no full commit, it reads the archive's substituted commit file; a full commit there names the archived revision, and an absent, unsubstituted or malformed file leaves the commit `unknown`.", "Neither the version nor the commit enters the graph, a projection or a generated contract, so a derived artifact stays a function of the corpus alone.", ], }, + design: { + archiveCommit: + "`.git-archive-commit` contains `$Format:%H$` in a checkout and is marked `export-subst` in `.gitattributes`, so git archives carry their full commit. The build reads the trimmed file only as a full Git object name, never as a command or an abbreviated hash.", + }, }, deliveryFacts: ["implemented", "has-verifier"], }, @@ -2232,9 +2055,10 @@ export const consumersSpecs = [ "`sdp validate --watch [root]` installs its watcher first, then runs the same validate path `sdp validate` runs, from scratch, once at start and again after every carrier create, change, delete or rename under the root.", "A carrier ends in `.sdp.md`, `.sdp.gherkin` or `.sdp.ts`. The watcher ignores the directories discovery skips, the configured exclusion prefixes and every non-carrier path; a removed watched directory and an event with no filename each schedule a run because their affected carriers cannot be named.", "Events that arrive while a run is in progress coalesce into one pending run.", - "Findings print as `sdp validate` prints them and the process stays alive; an operator stop exits 0.", + "Findings print as `sdp validate` prints them and the process stays alive; operator SIGINT terminates the process with exit 0 even while a rules body is active, without waiting for it. No further rules file runs or output is written after the stop.", "`--watch` is a validate option only and cannot combine with `--check-clean`. It re-runs on carrier edits and never on source edits, so an author re-runs validation after editing an anchor.", ], + ruleKeys: { operatorStop: "rules[3]" }, }, }, deliveryFacts: ["implemented", "has-verifier"], @@ -2261,7 +2085,7 @@ export const consumersSpecs = [ blocking: false, key: "combinationCoverage", decider: "owner", - links: ["spec:consumers.spec-studio.verification-panels#question.coverageSource"], + links: ["spec:consumers.spec-studio.verification-panels#ui.coverageGrid"], provisional: "the recipe reports per-slot literal-union values", }, { @@ -2341,7 +2165,7 @@ export const consumersSpecs = [ file: "specs/consumers/context-bundle.sdp.md", title: "A context bundle is composed per invocation within a stated budget", narrative: - "The original design precomputed graph slices for different reading scopes and a prompt bundle for one Spec. The Protocol keeps context bundle for the reading material of one design pass, composed on demand.\n\nThe first adopter's design passes assemble the same reading material by hand: the Pack design, member questions, dependency footing, pinned declarations and carrying Spec text where the graph answer falls short. Its perspectives read the same Pack for different concerns. The agent front door composes a bundle on each invocation; the bundle is never a committed answer in the graph's name. It builds on Pack design.", + "The original design precomputed graph slices for different reading scopes and a prompt bundle for one Spec. The Protocol keeps context bundle for the reading material of one design pass, composed on demand.\n\nThe first adopter's design passes assemble the same reading material by hand: the Pack design, member questions, dependency footing, pinned declarations and carrying Spec text where the graph answer falls short. Its perspectives read the same Pack for different concerns. The agent front door composes a bundle on each invocation; the bundle is never a committed answer in the graph's name. It builds on Pack design.\n\nAt an adopter's scale a bundle is mostly structure, so its shape is compact. Each member and each example row names its carrier file once. A cut entry without an entry address keeps only its line, since its row and its place in the bundle already name it. A question, a pinned value or a member example's steps appears in one place. Pinned declarations stay whole by default, because a builder and a test author hold to them. A caller whose scope still does not fit may let the bundle cut them to their entry addresses, and then reads each one in its Spec when the pass needs it. A partial bundle stays refused, because a bundle missing members would read as the whole scope.", sections: { intent: { actor: "An agent starting one design pass over a Pack or an ordered list of Specs.", @@ -2349,17 +2173,6 @@ export const consumersSpecs = [ "Each pass assembles the same reading material by running the same recipes and joining their answers by hand, and nothing bounds what it hands the model.", outcome: "Give an agent the reading material for one design pass in one composed slice of the graph within a stated budget, so no session hand-assembles it.", - openQuestions: [ - { - question: - "No cut reduces a pinned declaration, so a Pack whose pinned declarations alone exceed the budget fits no bundle; may a caller ask the bundle to reduce pinned declarations to their entry addresses?", - blocking: false, - key: "declarationCut", - decider: "owner", - provisional: - "pinned declarations stay whole, and a caller narrows the scope to fewer members, such as one perspective's", - }, - ], }, behavior: { rules: [ @@ -2369,31 +2182,49 @@ export const consumersSpecs = [ "With `pack`, a bundle may take `members`, an ordered subset of the Pack's member ids, which the perspective filter returns; it keeps the Pack's framing and boundary, lists only those members in that order, and states that it is a subset, so one filter implementation serves both recipes.", "A bundle for a Pack carries the Pack design assembly, its members in authored order with their design columns and its boundary, then each member's open questions with their addresses and lines, its dependency footing, its pinned declarations and its examples.", "The budget is a hard limit on a successful bundle, counted in UTF-16 code units, the `length` a recipe body reads, of the compact `JSON.stringify` of the value the recipe returns, its scope, budget and left-out statement included; `sdp q --json` prints a longer indented form, and the budget never measures printed text. The bundle also reports a token estimate labeled as an estimate, so no model's tokenizer binds the Protocol.", - "When the scope exceeds the budget, the bundle cuts in a fixed order: boundary rows down to their ids, member narrative, example step text, non-blocking questions, then remaining section values other than protected content. It never cuts member ids, the stated rung, the floor reached, the stated next rung, blocking questions, pinned declarations or entry addresses.", + "A bundle carries a member's open questions only in its `questions`, a pinned declaration's value only in its member's Design section, and an example's steps only in its own member row when the example is a member of the bundle; an example outside the bundle keeps its steps in the row of each member it refines or verifies.", + "When the scope exceeds the budget, the bundle cuts in a fixed order and stops at the first cut after which it fits: boundary rows down to their ids, footing rows down to their ids, member narrative, example step text, non-blocking question text, remaining section values other than pinned declarations, then, only when the caller allows it, pinned declarations down to their entry addresses. It never cuts member ids, the stated rung, the floor reached, the stated next rung, blocking questions or entry addresses, and it cuts nothing else.", + 'A caller allows the last cut by passing `declarations: "addresses"`; without it, or with `declarations: "whole"`, pinned declarations stay whole, and any other value is a diagnostic result.', "When the protected content alone exceeds the budget after every permitted cut, the recipe returns a budget-exceeded result as data and no partial bundle: the requested scope, the budget, and the minimum count the protected content needs in the same unit; `sdp q` exits 0, because the result is the body's return and not a refusal.", "A bundle gives the file and line of an entry or a binding in place of a source excerpt, because the reader never reads source.", "A bundle states its scope, its budget and what it left out to stay within the budget, so a reader knows what to read in the Specs themselves.", "A bundle carries facts from the graph and no instructions of its own; the brief that tells an agent what to look for stays the corpus's own text.", ], ruleKeys: { - cutOrder: "rules[6]", + oneCopy: "rules[6]", + cutOrder: "rules[7]", + declarationAddresses: "rules[8]", }, }, design: { bundleRecipe: - "recipe 36, the context bundle, takes exactly one scope, `params.pack` or `params.specs`, an optional `params.members` beside `pack`, and `params.budget`, 100,000 code units by default; a missing, doubled or unknown scope returns `{ diagnostics }` and no bundle.", + 'recipe 36, the context bundle, takes exactly one scope, `params.pack` or `params.specs`, an optional `params.members` beside `pack`, `params.budget`, 100,000 code units by default, and `params.declarations`, `"whole"` by default or `"addresses"`; a missing, doubled or unknown scope, or another `declarations` value, returns `{ diagnostics }` and no bundle.', bundleShape: - "`{ scope, budget, used, tokenEstimate: { value, method }, leftOut, pack, members, boundary }`, where `pack` is `{ id, title, framing }` for a Pack scope and null otherwise, and `tokenEstimate.method` reads `UTF-16 code units divided by four, an estimate`.", + "`{ scope, budget, declarations, used, tokenEstimate: { value, method }, leftOut, pack, members, boundary }`, where `declarations` is the mode in force, `pack` is `{ id, title, framing }` for a Pack scope and null otherwise, and `tokenEstimate.method` reads `UTF-16 code units divided by four, an estimate`.", memberShape: - "`{ id, title, specKind, altitude, statedReadiness, floorReached, statedNextRung, holds, narrative, sections, questions, footing, declarations, examples }`, with questions carrying their address, blocking flag, line and fields, footing as dependency footing (recipe 21) reports it, declarations as pinned declarations (recipe 24) report them, and examples with their stated rung, whether they are enabled and their steps. `holds` lists the member's stated-next-rung failures as the reader returns them, each with its `clauseId` and `description`, and with `targets` on a typed-dependency failure.", + "`{ id, file, title, specKind, altitude, statedReadiness, floorReached, statedNextRung, holds, narrative, sections, questions, footing, declarations, examples, design, decisions, implementedBy, referencedBy, verifiers }`, with `file` the member's carrier path; `sections` as the graph holds them, without `intent.openQuestions`; `questions` one row per open question in authored order, `{ index, question, blocking, key, address, line }` with its fields, `key` absent and `address` null when it has none; `footing` as dependency footing (recipe 21) reports it; `declarations` the keys of the member's pinned declarations in Design order; `design` the counts of entries, declarations, open questions and blocking questions; `decisions` the decisions its design columns name; `implementedBy` and `referencedBy` as `{ id, claim, file, line, role, component }`; and `verifiers` as `{ id, claim, enabled, file, line }`. `holds` lists the member's stated-next-rung failures as the reader returns them, each with its `clauseId` and `description`, and with `targets` on a typed-dependency failure.", + exampleRow: + "`{ id, member, file, statedReadiness, enabled, steps }` lists one example that refines or verifies the member, in id order, with `member` true and `steps` null when the example is itself a member of the bundle, its steps then sitting in its own member row.", + pointer: + "`{ line }` replaces a cut entry that has no entry address, `line` being the line the location table records for it, or null when it records none; the entry's Spec and file are those of the row that holds it, and its place in the bundle names its path. A cut entry whose text names other entry addresses becomes a list of them, led by its own address when it has one and otherwise ended by its pointer.", boundaryRows: "a Pack scope's boundary is the Pack design's; a Spec-list scope's boundary is every Spec outside the list one authored relation away from a listed Spec, in the same row shape.", - fifthCut: - "The fifth cut reduces an addressable entry to its entry address and another entry to `{ spec, path, file, line }`, with `line` null when not located. Other entry addresses in removed text stay beside that pointer, and non-blocking questions retain their pointers and links; pinned declarations and blocking questions stay whole.", - cutEverywhere: - "every cut removes the affected text from each place it appears in the bundle, `sections` included, and blocking questions, pinned declarations and entry addresses stay whole in each place.", + footingCut: + "`{ refines, dependsOn, constrainedBy, decidedBy }` replaces a member's footing at the footing cut, each a list of target ids in code-unit order, with a type that has no target left out.", + narrativeCut: + "The narrative cut replaces a member's narrative with the entry addresses written in it, in order of first appearance and joined by single spaces, or with null when it holds none.", + exampleCut: + "The example cut replaces each entry of a member's `behavior.examples`, and its `behavior.exampleSpace`, with its pointer, and each example row's `steps` that are not null with the pointer of that example's `behavior.examples`.", + questionCut: + "The question cut replaces each non-blocking question in `questions` with its address, or with its pointer when it has no key, and with `{ pointer, links }` when it has links, `pointer` being that address or pointer; blocking questions stay whole.", + sectionCut: + "The section cut replaces each section entry other than a pinned declaration with its entry address when it has one, and otherwise with its pointer.", + declarationCut: + "The declaration cut, taken only under `declarations: \"addresses\"`, replaces each pinned declaration in its member's Design section with its entry address, or with its pointer when its key has none; the member's `declarations` keeps the keys.", + leftOutLines: + "One fixed line per cut taken, in cut order, makes up `leftOut`: `Boundary details; ids kept per direction.`, `Footing details; ids kept per relation type.`, `Member narrative.`, `Example step text, including member sections and example-space vocabulary.`, `Non-blocking question text; addresses and links kept.`, `Section entry values other than pinned declarations; addresses and lines kept.` and `Pinned declaration text; entry addresses kept.`", budgetExceeded: - "`{ scope, budget, budgetExceeded: true, protectedLength, leftOut }` is the whole result when the protected content alone exceeds the budget, with `protectedLength` in the budget's unit.", + "`{ scope, budget, declarations, budgetExceeded: true, protectedLength, leftOut }` is the whole result when the protected content alone exceeds the budget after every permitted cut, with `protectedLength` in the budget's unit.", }, }, deliveryFacts: ["implemented", "has-verifier"], @@ -2630,7 +2461,7 @@ export const consumersSpecs = [ file: "specs/consumers/obsidian-vault.sdp.md", title: "An Obsidian vault lets a person iterate on designs from the one graph", narrative: - "The Design Review links a Spec's relations both ways and gives keyed Pack questions a separate carrier-line link, but it offers no built-in filtering of a Pack or navigation from arbitrary prose mentions to their entries. The Spec Studio is designed for both a person who iterates on a design in the repository and a pull request reviewer who holds only a link. A linked-notes view would let a person follow relations, filter members by rung and design columns, open entries by address and see each pass's edits arrive; whether it is the Studio's local half is the question below.\n\nObsidian already does that for linked Markdown notes with properties: backlinks, a graph view, search, and filterable tables it calls Bases. The prototype in `explorations/obsidian-vault/` wrote notes from dumped graph and reader values, with relations and checked mentions as wikilinks, entry addresses as block links, and Bases for Pack members and standing queries. Pack members could be read as a sortable table. The prototype joined relation lists and computed flat properties itself, and its dump renamed `statedNextRungFailures` to `nextRungFailures`; those shortcuts are evidence for the product design, not the reader's contract.\n\nThe principle is a linked-notes view regenerated from the one graph; Obsidian and its Bases are the representation chosen first. Like every projection, the vault states no truth, and two builds of one graph are byte-identical, so no note names a commit or a time: a publishing step may write a stamp beside the vault, as the Studio's sharing rule does. Intent is still written only in the carriers.", + "The Design Review links a Spec's relations both ways and gives keyed Pack questions a separate carrier-line link, but it offers no built-in filtering of a Pack or navigation from arbitrary prose mentions to their entries. The Spec Studio is designed for both a person who iterates on a design in the repository and a pull request reviewer who holds only a link. A linked-notes view would let a person follow relations, filter members by rung and design columns, open entries by address and see each pass's edits arrive. It is a projection of its own, not the Studio's local half: the Studio's page set serves a reader who holds a link, the vault serves a person who iterates in a notes app, and each has its own verb and root. It waits for a reader who asks for it once the Studio's page set exists.\n\nObsidian already does that for linked Markdown notes with properties: backlinks, a graph view, search, and filterable tables it calls Bases. The prototype in `explorations/obsidian-vault/` wrote notes from dumped graph and reader values, with relations and checked mentions as wikilinks, entry addresses as block links, and Bases for Pack members and standing queries. Pack members could be read as a sortable table. The prototype joined relation lists and computed flat properties itself, and its dump renamed `statedNextRungFailures` to `nextRungFailures`; those shortcuts are evidence for the product design, not the reader's contract.\n\nThe principle is a linked-notes view regenerated from the one graph; Obsidian and its Bases are the representation chosen first. Like every projection, the vault states no truth, and two builds of one graph are byte-identical, so no note names a commit or a time: a publishing step may write a stamp beside the vault, as the Studio's sharing rule does. Intent is still written only in the carriers.", sections: { intent: { actor: @@ -2643,16 +2474,6 @@ export const consumersSpecs = [ "A notes app invites editing, and an edit to a generated note is lost at the next regeneration without reaching the carrier.", ], openQuestions: [ - { - question: - "Is the vault a projection of its own, or the Studio's local half beside the static page set a reviewer with only a link reads?", - blocking: true, - key: "studioRelation", - decider: "owner", - provisional: - "a projection of its own, written by its own verb under `generated/obsidian-vault/`, which the Studio's design pass may adopt as its local half", - links: ["spec:consumers.spec-studio"], - }, { question: "Does the vault keep to the wikilinks and properties other notes tools read, or use Obsidian's Bases freely?", @@ -2660,6 +2481,7 @@ export const consumersSpecs = [ key: "otherNotesTools", provisional: "notes and links stay plain Markdown with wikilinks any such tool reads, and the Bases are Obsidian's alone", + decider: "owner", }, { question: @@ -2669,10 +2491,18 @@ export const consumersSpecs = [ decider: "owner", provisional: "the generator owns only its generated note folders and Bases; Obsidian settings and authored intent notes sit outside that owned set, and a completed regeneration removes stale generated notes", + trigger: "a person asks for a vault beside the Studio's static page set", }, ], }, - behavior: {}, + behavior: { + rules: [ + "The vault is a projection of its own, written by its own verb under `generated/obsidian-vault/`, and no part of the Studio's page set.", + ], + ruleKeys: { + ownProjection: "rules[0]", + }, + }, }, deliveryFacts: [], }, @@ -2885,4 +2715,335 @@ export const consumersSpecs = [ }, deliveryFacts: ["implemented", "has-verifier"], }, + { + id: "spec:consumers.spec-studio.index-page", + specKind: "behavior", + altitude: "story", + readiness: "ready", + file: "specs/consumers/spec-studio.index-page.sdp.md", + title: "The index says where the corpus stands and narrows every Spec", + narrative: + "A reader of a large design needs two ways in: by the task they came to do and by a word they remember. The index gives the first and links to search for the second, opens with a summary the build counts, and replaces one long table with a list a person narrows.", + sections: { + intent: { + outcome: + "Give a person one landing page that says where the corpus stands, routes them by task, lists every Pack with its members, and narrows every Spec by the coordinates and bindings they ask about.", + }, + behavior: { + rules: [ + "The page opens with a summary the build counts from reader values, each count in a sentence: the Specs and Packs; the Specs at each stated rung; the Specs stated above their floor; the open and blocking questions; the Specs with an implementation binding and with a verifier binding; and the findings by severity.", + "A short list then routes by task, in fixed words that quote nothing from the corpus: read a Pack's design, find a Spec, entry or file by a word, narrow the Specs, and read the findings; each route links to its place in the page set.", + "The Packs section lists every Pack in id order with its title and its members in authored order, each member with its stated rung beside its floor; it draws no capability tree.", + "The Specs section lists every Spec in id order, one row each: its title and id, kind and altitude, stated rung beside the floor reached, implementation binding and verifier binding as present or none, open and blocking questions, and its Packs.", + "Filters narrow the Specs section by kind, altitude, stated rung, floor reached, implementation binding, verifier binding and Pack; each filter takes one value or any, the filters combine, and the section says how many of its rows it shows. Without script every row shows.", + "The findings section lists the validation report's findings in its order, each with its severity in words, its validator id, its subject when it has one, linked to its page when it is a Spec or a Pack and as text otherwise, its message, and its recorded file and line as text.", + ], + ruleKeys: { + summary: "rules[0]", + taskRoutes: "rules[1]", + packsLens: "rules[2]", + specRows: "rules[3]", + filters: "rules[4]", + findings: "rules[5]", + }, + }, + ui: { + summary: "a short list of sentences under the page title, each with a count.", + taskRoutes: + "four links under a `Start here` heading: Read a Pack's design, Find a Spec, entry or file by a word, Narrow the Specs, Read the findings.", + packList: + "one closed disclosure per Pack, its summary line the Pack's title, id and member count, its body the members in authored order as links with their rung badges.", + specRow: + "one line per Spec: the title linked to its page, the id as code, the rung badge, then as plain text the kind, altitude, floor in words, the two binding values, the question counts and the Packs; the line wraps on a narrow screen.", + filterBar: + "one labelled select per filter above the Specs, each listing any and the values the rows hold, and a line saying how many of all rows show.", + sectionIds: + "the Packs, Specs and findings sections carry the element ids `packs`, `specs` and `findings`, which the top navigation, the rail and the task routes link to.", + findingsList: "one line per finding, the severity word first.", + }, + }, + deliveryFacts: ["implemented", "has-verifier"], + }, + { + id: "spec:consumers.spec-studio.pack-page", + specKind: "behavior", + altitude: "story", + readiness: "ready", + file: "specs/consumers/spec-studio.pack-page.sdp.md", + title: "A Pack page shows the Pack's design as one card per member", + narrative: + "The Markdown Pack page renders the Pack design as one wide member table, and the first adopter's reviewers read carrier files instead. This page renders the same assembly as a summary, then a card per member that a reviewer opens where they need the detail.", + sections: { + intent: { + outcome: + "Show where a Pack's design stands, member by member in reading order, on one page a reviewer scans quickly and opens where they need detail.", + openQuestions: [ + { + key: "summaryFormat", + question: "Should the summary state its counts as sentences or as a table?", + blocking: false, + decider: "owner", + provisional: "Keep the counts as sentences.", + }, + ], + }, + behavior: { + rules: [ + "The page opens with the Pack's title, its id and its carrier file as text with a copy action, then the summary; the framing follows as prose under the heading Framing, with the vocabulary references, linked.", + "The summary states each Pack total in one sentence that opens with its count: the members and the unresolved ones, the members held below their stated next rung and those waiting only for their author, the design entries and pinned declarations, the open and blocking questions, the members with an implementation binding and with a design reference, and the boundary in each direction.", + "The summary's held sentence links each held member to its card with its count of unmet clauses, and its waiting sentence names the members whose stated next rung waits only for their author's statement unless every member does; the page never presents a rung as approved.", + "The members follow in authored order, one closed card each. A card's summary line shows the member's position, title and kind, its stated rung beside the floor reached, its stated next rung with `nothing blocks` or how many of its clauses are unmet, its open and blocking questions, and its implementation and verifier bindings as present or none.", + "An open card shows the member's id, linked to its Spec page, then the parts the next five rules state, in their order.", + "A card lists each clause unmet for the member's stated next rung, with the targets the clause names and their stated rungs.", + "A card counts the member's design entries and pinned declarations, and links the decisions it names, each with its stated rung.", + "A card lists each open question with its blocking flag in words, its address when it is keyed, linked to the question on the Spec page, its recorded line, its text, and its decider, links, provisional reading and trigger when present.", + "A card lists each unit that implements the member and each that references it, by id, label, and recorded file and line as text with a copy action, with its role and its component's id, layer and context.", + "A card states the member's verifier binding as present or none and, when it has any, counts its verifiers and verifying examples, each beside how many are enabled.", + "An unresolved member's card says only that the manifest names an id the graph does not hold.", + "The boundary follows in four closed sections, Rests on, Rested on by, Verified by and Superseded by, each headed with its count. Each row shows the outside Spec, linked, its stated rung, its implementation binding as present or none, and the members it joins under each relation type; an empty section reads None.", + "Then come the members with no verifier binding, those stated `ready` first, under one sentence saying the graph records bindings and never runs, so no page shows a run result; then the findings that name the Pack.", + "`Open all` and `Close all` open or close every card and section at once, and opening the page at a card's fragment opens that card.", + ], + ruleKeys: { + header: "rules[0]", + summary: "rules[1]", + holds: "rules[2]", + memberCards: "rules[3]", + cardBody: "rules[4]", + cardHolds: "rules[5]", + cardDesign: "rules[6]", + cardQuestions: "rules[7]", + cardCode: "rules[8]", + cardVerifiers: "rules[9]", + boundary: "rules[11]", + openAll: "rules[13]", + }, + }, + ui: { + headerBlock: + "the title as the page heading; below it the id and the carrier file, each with a copy action; then the summary; then the framing under a `Framing` heading, and the vocabulary references.", + summaryList: + "the totals as a short list of sentences under a `Where the design stands` heading, each opening with its count, a zero written as No.", + memberCard: + "a disclosure whose element id is the member's Spec id; its summary line reads `. `, then the kind, the rung badge, the floor in words, the next rung in words unless the member states ready, the question counts and the two binding values, all but the badge as plain text, wrapping onto further lines on a narrow screen.", + cardBody: + "in order, the id link, the unmet clauses, the design counts and decisions, the open questions, the code, and the verifier binding.", + questionItem: + "the blocking flag in words; for a keyed question, `question.<key>` as a link to its address with a copy action for the full address; `line <n>` when recorded; the text; then each present field on its own labelled line.", + unitItem: + "the unit id as code, its label, `<file>:<line>` with a copy action, its role, and its component with layer and context.", + boundaryTable: + "four columns, Spec, stated rung, implementation binding and joined members, the last listing each relation type with its members.", + }, + }, + deliveryFacts: ["implemented", "has-verifier"], + }, + { + id: "spec:consumers.spec-studio.spec-page.core", + specKind: "behavior", + altitude: "story", + readiness: "ready", + file: "specs/consumers/spec-studio.spec-page.core.sdp.md", + title: "A Spec page's core shows the whole Spec, with an anchor for every entry", + narrative: + "The full Spec page adds panels to this core: diagrams, a runtime neighborhood, verification and evidence panels, impact, and actions that compose intent. The core shows what the Spec context holds, as text, so a reviewer with only a link reads the whole Spec and can cite any entry by its address.", + sections: { + intent: { + outcome: + "Show everything the graph holds about one Spec on one page that reads without script: its header, its sections in order with an anchor for every addressable entry, its relations both ways, its bindings and its findings.", + }, + behavior: { + rules: [ + "The header shows the title; the id with a copy action; the kind's display label and the altitude; the stated rung beside the floor reached on one line, with the divergence banner only in the dishonest direction; the stated next rung with `nothing blocks` or each unmet clause and its targets; the Packs the Spec belongs to, linked; its design columns; its findings by severity; and its carrier file as text with a copy action. It shows no owner, capability, tag or last-verified time: the envelope holds none, the Packs stand for a capability, and a binding never records a run.", + "A summary in words follows the header with what the header does not show: the open and blocking questions, and the implementation, verifier and oracle bindings as present or none. It repeats no rung the header shows.", + "The narrative comes next, then each section the Spec carries as a panel open by default, in the order intent, behavior, constraints, model, design, decision, verification, ui, with its entries in authored order. A section the Spec lacks is absent, and the page adds no section the graph does not hold. The narrative carries the element id `narrative` and each section panel `section.<name>`, such as `section.behavior`.", + "Every entry an entry address can name, a keyed Design or UI entry, a keyed rule or flow and a keyed open question, carries an element id equal to its address after the `#`, such as `design.<key>`, and shows its key and its recorded line.", + "Each open question shows its blocking flag in words, its key as code with a copy action for the full entry address when it has one, its text, its decider, its links, linked, its provisional reading and its trigger when present, and its recorded line. It shows no date: git records when a line changed.", + "A `gwt` or `gwt-vocabulary` fence shows as preformatted step text, as authored.", + "Relations show both ways, each with its type, its direction, its claim, the other endpoint, linked when it is a Spec or a Pack, whether it resolves, and its reason and the rule keys it exercises when present.", + "Bindings speak binding language. The implementation binding, design reference, verifier binding and expected-outcome oracle each read present or none, and list each unit, test anchor, example or oracle by id, label, and recorded file and line as text with a copy action; a unit adds its role and component, and an example verifier says whether it is enabled. Runtime observation reads not tracked, and run results read not recorded in the graph.", + "The findings that name the Spec close the page, each with its severity in words, its validator id and its message.", + ], + ruleKeys: { + header: "rules[0]", + summary: "rules[1]", + sections: "rules[2]", + entryIds: "rules[3]", + questions: "rules[4]", + relations: "rules[6]", + bindings: "rules[7]", + findings: "rules[8]", + }, + }, + ui: { + headerBlock: + "the title as the page heading; below it a line with the id, kind, altitude, the rung badge with the floor in words, and the next rung; then the Packs, the design columns, the finding counts and the carrier file.", + summaryLine: "one sentence under the header.", + sectionPanel: + "an open disclosure per section, headed with the section's name, and for Verification its mode.", + entryRow: + "an entry's key as code, its value, and `line <n>` when recorded, in a definition list.", + questionItem: + "the blocking flag in words, `question.<key>` as code when keyed, a copy action whose text and accessible name carry the full entry address, with no second code span of that address, the text, each present field on its own labelled line, and `line <n>` when recorded.", + relationsTable: + "five columns, relation and direction, other end, claim, reason, and exercised keys, with an unresolved endpoint marked in words.", + bindingsBlock: + "four labelled lines, each present or none with its items below it, then runtime observation and run results.", + findingsList: "one line per finding, the severity word first.", + }, + }, + deliveryFacts: ["implemented", "has-verifier"], + }, + { + id: "spec:consumers.spec-studio.search", + specKind: "behavior", + altitude: "story", + readiness: "ready", + file: "specs/consumers/spec-studio.search.sdp.md", + title: "Search finds a Spec, a Pack, an entry or a bound file by a word", + narrative: + "The first adopter asked to search entry keys, pinned declarations and file paths. A Spec's envelope holds no tags, so search reads what the graph holds: the fields concept search reads, which already include every pinned declaration, plus the keys authors coin and the files the graph records. The page matches with the reader's own code, so a term finds on the page what `findByConcept` finds through `sdp q`.", + sections: { + intent: { + outcome: + "Let a person find a Spec, a Pack, a keyed entry or the code bound at a file by a word they remember of it, from any page and with no request.", + }, + behavior: { + rules: [ + "Search matches with the reader's concept matching, one implementation the package exports without Node dependencies and the page script bundles, so over the fields `findByConcept` reads, the page returns the nodes it returns, in its order.", + "Search also matches the keys authors coin, Design and UI keys, open question keys and rule keys, and a node's recorded file path; a node matched only through these follows every node `findByConcept` matches, in id order. It indexes nothing else.", + "A term matches where it appears in a field, without regard to case, as `findByConcept` matches; an empty term matches nothing.", + "The search field on any page opens the search page with the term in its fragment, `search.html#q=<term>`; the search page matches as the person types and keeps the term in its fragment, so a search is a link.", + "Each result names its node type in words. A Spec result shows its title, id, kind, and stated rung beside the floor reached, and opens its page; a Pack result shows its title and id and opens its page, with no readiness; an Anchor or CodeNode result shows its id, label, and recorded file and line as text with a copy action, and links each Spec its edges reach, with the edge type.", + "A Spec with a matching entry is a result. Under a Spec result, the page lists the Spec's entries the term matches, as `spec:consumers.spec-studio.data#design.searchEntry` states, in the index's order, each with its section, key and recorded line, linked to the entry's element id on the Spec page when it has an entry address, and otherwise to its section's element id, or to the narrative's.", + "The page shows fifty results at a time, with a control that shows the next fifty.", + "The search page reads only the index it embeds; it fetches nothing and joins nothing.", + ], + ruleKeys: { + oneMatcher: "rules[0]", + addedFields: "rules[1]", + searchPage: "rules[3]", + resultShape: "rules[4]", + entryHits: "rules[5]", + }, + }, + ui: { + searchField: + "a labelled text field and a Search button in the top navigation of every page.", + resultsHeader: "the term, how many nodes and entries match, and the fields searched.", + resultCard: + "one card per node, in result order: its node type in words, title and id, the fields that matched, and the Spec's rungs or the unit's location as the result rule states; then its entry hits.", + entryHit: + "one line per entry: its section, its key as code, its recorded line, and the text around the term, shortened.", + noMatch: "`No node matches` and the term, then the fields searched.", + needsScript: + "without script, the search page says that search needs it and links to the index.", + }, + }, + deliveryFacts: ["implemented", "has-verifier"], + }, + { + id: "spec:consumers.spec-studio.publisher", + specKind: "behavior", + altitude: "story", + readiness: "ready", + file: "specs/consumers/spec-studio.publisher.sdp.md", + title: "`sdp studio` publishes the Studio as one page set", + narrative: + "The Studio has its own verb and root beside the four shipped publishers, and is written the way they are. Its page set opens from the file system and uploads to a static host unchanged. The run adds no stamp, commit or time, so two runs over one graph write the same bytes, and a publishing step that wants build context writes its stamp beside the page set.", + sections: { + intent: { + outcome: + "Publish the Studio under its own root as one page set the graph alone determines, which opens through `file://` and uploads to a static host unchanged.", + }, + behavior: { + rules: [ + "`sdp studio [root] [--exclude PATH]... [--check-clean]` validates the graph and writes the Studio's page set to `<root>/generated/spec-studio/`; `root` defaults to the working directory, and each exclusion is a root-relative path prefix, as for the shipped publishers.", + "A run writes nothing outside its root, writes no stamp, and leaves every carrier and source file under the root byte-identical.", + "A run writes its page set to `generated/spec-studio.tmp/`, removes the prior root and renames the temporary into place, so no page of an earlier run survives a later one and no temporary survives a completed run.", + "Every command that enters the shared build, `sdp studio` among them, removes `generated/spec-studio/` and its temporary sibling before extraction, beside the four roots `spec:consumers.wholesale-view-rewrite#rule.buildInvalidates` names; a removal failure refuses the command before extraction.", + "`sdp studio --check-clean` renders an independent twin and refuses divergent renders, then compares the current root with the new render; missing or drifted output returns nonzero and is removed, and clean output is replaced wholesale with byte-identical content.", + "Findings never withhold the page set: a run over a graph with validation findings still publishes and returns the validation exit code, and each finding shows on the index, and also on its subject's page when its subject is a Spec or a Pack. A run whose extraction fails publishes nothing and returns nonzero.", + "The page set holds `index.html`, `search.html`, one `packs/<name>.html` per Pack, one `specs/<name>.html` per Spec, and `assets/studio.css` and `assets/studio.js`. `<name>` is the id after its namespace, with each uppercase ASCII letter written as `_` and its lowercase, so no two pages collide on a file system that ignores case.", + "Two runs over one graph write byte-identical files, and no file holds a time, a commit, an absolute path or a host name.", + "Every link a page writes stays inside the page set: it resolves to a page the run wrote and, with a fragment, to an element id that page carries, apart from the search page's term fragment `search.html#q=<term>`, which names no element. No page links a carrier or source file, an editor URI or an external address.", + ], + exampleSpace: { + given: [ + 'an extraction root holding {corpus:"a Pack and its member Specs"|"a Spec with a validation error"|"a carrier the extractor refuses"} and a stale Studio page {stalePage:string}', + ], + when: [ + "the studio command runs twice at that root, with check-clean {checkClean:boolean}", + ], + then: [ + "each run exits {exitCode:number}", + "the page set holds the entry page {entryPage:string}", + "the two runs wrote byte-identical files: {byteIdentical:boolean}", + "the stale page survives: {staleSurvives:boolean}", + "a temporary Studio sibling survives: {temporarySurvives:boolean}", + "every carrier and source file under the root stays byte-identical: {rootUntouched:boolean}", + "every link a page writes resolves inside the page set: {linksResolve:boolean}", + ], + }, + ruleKeys: { + verb: "rules[0]", + ownRoot: "rules[1]", + wholesale: "rules[2]", + buildInvalidates: "rules[3]", + checkClean: "rules[4]", + findingsShown: "rules[5]", + pageSet: "rules[6]", + deterministic: "rules[7]", + linksResolve: "rules[8]", + }, + }, + }, + deliveryFacts: ["implemented", "has-verifier"], + }, + { + id: "spec:consumers.spec-studio.page-elements", + specKind: "contract", + altitude: "story", + readiness: "ready", + file: "specs/consumers/spec-studio.page-elements.sdp.md", + title: "A few custom elements filter, search, open and copy on a rendered page", + narrative: + "The build renders every page whole, so these elements add behavior to markup that is already there rather than rendering views of their own. A browser refuses a module script on a page opened through `file://`, so they ship as one classic script.", + sections: { + intent: { + outcome: + "Give the Studio's pages their few interactions through custom elements that enhance markup the build wrote, so a page with its script filters, searches and copies, and every page but the search page still reads in full without it.", + }, + behavior: { + rules: [ + "The elements ship in one classic script, `assets/studio.js`, with no module syntax and nothing loaded at run time; the build bundles it from the package's source, with the concept matcher.", + "Each element enhances markup the build wrote and renders no graph value of its own, except that the search results element renders values from the search index its page embeds.", + "No element requests anything on a page opened through `file://`; the stamp element requests `stamp.json` only over http or https.", + "Every control an element adds is a native button, link, select or input with a text label, reachable from the keyboard in reading order.", + "An element that changes what a page shows says so in words in a polite live region, such as how many rows a filter shows.", + ], + ruleKeys: { + oneScript: "rules[0]", + enhanceOnly: "rules[1]", + noRequest: "rules[2]", + nativeControls: "rules[3]", + liveCounts: "rules[4]", + }, + }, + design: { + search: + "`sdp-search` with `page-root`, the relative path from its page to the page-set root: wraps the search field's form and, on submit, opens `<page-root>search.html#q=<term>` with the term percent-encoded.", + searchResults: + "`sdp-search-results`: on the search page, reads the index from the `sdp-data` element, matches the fragment's term and each term the person types, writes the results `spec:consumers.spec-studio.search` designs, and keeps the term in the fragment.", + filter: + '`sdp-filter`: wraps a hidden form of labelled `select` elements, each with `data-facet="<name>"` and an `any` option, and the rows marked `data-row`. On upgrade it shows the form, hides each row whose `data-<name>` differs from a chosen value, and writes `<shown> of <total> shown` in its `output` element; a row whose attribute holds several space-separated values, such as its Packs, matches when one of them equals the choice.', + expander: + "`sdp-expander`: adds `Open all` and `Close all` buttons for the `details` elements inside it, and on load and on each fragment change opens every `details` that holds the fragment's target and scrolls the target into view.", + copy: "`sdp-copy` with `text`: a `Copy` button whose accessible name includes the text; it copies the text to the clipboard and says `Copied`, and where the clipboard is unavailable it selects the text so the person can copy it.", + stamp: + "`sdp-stamp` with `page-root`: served over http or https, it requests `<page-root>stamp.json` and, when it loads, shows its corpus commit, engine and build time as text; otherwise it shows nothing.", + }, + }, + deliveryFacts: ["implemented", "has-verifier"], + }, ] as const; diff --git a/test/self-hosting-oracle/declared-relations.ts b/test/self-hosting-oracle/declared-relations.ts index 854c4859..bc0485f7 100644 --- a/test/self-hosting-oracle/declared-relations.ts +++ b/test/self-hosting-oracle/declared-relations.ts @@ -1979,4 +1979,219 @@ export const expectedDeclaredRelations: readonly (readonly [ ["spec:consumers.reader", "decidedBy", "spec:decisions.agent-front-door"], ["spec:consumers.reader", "decidedBy", "spec:decisions.anchor-binding-grain"], ["spec:consumers.reader", "decidedBy", "spec:decisions.decision-readiness-posture"], + [ + "spec:consumers.agent-surface.register-recipes", + "dependsOn", + "spec:extraction.entry-locations", + "the key-family register gives each row the line the location table records", + ], + [ + "spec:validation.corpus-rules", + "dependsOn", + "spec:consumers.agent-surface.evaluation-sink", + "a rules file holds the body the sink evaluates, with the sink's bindings and trust", + ], + [ + "spec:validation.corpus-rules", + "dependsOn", + "spec:consumers.agent-surface.recipe-parameters", + "validate takes `--params` in the sink's grammar", + ], + [ + "spec:validation.corpus-rules", + "dependsOn", + "spec:consumers.validate-watch", + "the watch loop reruns the named rules in each cycle", + ], + [ + "spec:consumers.spec-studio.shell", + "dependsOn", + "spec:consumers.spec-studio.responsive", + "the rail moves below the canvas under the phone breakpoint", + ], + [ + "spec:consumers.spec-studio.shell", + "dependsOn", + "spec:validation.prose-mentions", + "a mention links where the validator resolves it", + ], + [ + "spec:consumers.spec-studio.shell", + "dependsOn", + "spec:consumers.spec-studio.sharing", + "the shell shows the stamp a publishing step writes beside the page set", + ], + [ + "spec:consumers.spec-studio.index-page", + "refines", + "spec:consumers.spec-studio.lenses", + "the index is where the Packs lens lands", + ], + ["spec:consumers.spec-studio.index-page", "dependsOn", "spec:consumers.spec-studio.data"], + ["spec:consumers.spec-studio.index-page", "dependsOn", "spec:consumers.spec-studio.shell"], + ["spec:consumers.spec-studio.index-page", "dependsOn", "spec:consumers.derived-readiness-banner"], + ["spec:consumers.spec-studio.index-page", "dependsOn", "spec:consumers.binding-language-views"], + ["spec:consumers.spec-studio.pack-page", "refines", "spec:consumers.spec-studio"], + ["spec:consumers.spec-studio.pack-page", "dependsOn", "spec:consumers.spec-studio.data"], + ["spec:consumers.spec-studio.pack-page", "dependsOn", "spec:consumers.spec-studio.shell"], + [ + "spec:consumers.spec-studio.pack-page", + "dependsOn", + "spec:consumers.pack-design", + "the page renders the Pack design the Pack context carries", + ], + ["spec:consumers.spec-studio.pack-page", "dependsOn", "spec:consumers.derived-readiness-banner"], + ["spec:consumers.spec-studio.pack-page", "dependsOn", "spec:consumers.binding-language-views"], + ["spec:consumers.spec-studio.pack-page", "dependsOn", "spec:model.open-question-fields"], + ["spec:consumers.spec-studio.spec-page.core", "refines", "spec:consumers.spec-studio.spec-page"], + ["spec:consumers.spec-studio.spec-page.core", "dependsOn", "spec:consumers.spec-studio.data"], + ["spec:consumers.spec-studio.spec-page.core", "dependsOn", "spec:consumers.spec-studio.shell"], + [ + "spec:consumers.spec-studio.spec-page.core", + "dependsOn", + "spec:consumers.derived-readiness-banner", + ], + [ + "spec:consumers.spec-studio.spec-page.core", + "dependsOn", + "spec:consumers.binding-language-views", + ], + ["spec:consumers.spec-studio.spec-page.core", "dependsOn", "spec:model.open-question-fields"], + ["spec:consumers.spec-studio.spec-page.core", "dependsOn", "spec:model.rule-keys"], + ["spec:consumers.spec-studio.spec-page.core", "dependsOn", "spec:model.relation-reasons"], + ["spec:consumers.spec-studio.spec-page.core", "dependsOn", "spec:extraction.entry-locations"], + ["spec:consumers.spec-studio.spec-page.core", "decidedBy", "spec:decisions.authored-entry-order"], + ["spec:consumers.spec-studio.search", "refines", "spec:consumers.spec-studio.shell"], + ["spec:consumers.spec-studio.search", "dependsOn", "spec:consumers.spec-studio.data"], + [ + "spec:consumers.spec-studio.search", + "dependsOn", + "spec:consumers.reader", + "the page matches with the reader's concept matching", + ], + ["spec:consumers.spec-studio.publisher", "refines", "spec:consumers.spec-studio"], + ["spec:consumers.spec-studio.publisher", "dependsOn", "spec:consumers.spec-studio.data"], + [ + "spec:consumers.spec-studio.publisher", + "dependsOn", + "spec:consumers.wholesale-view-rewrite", + "the Studio's root is written, invalidated and certified the way the shipped roots are", + ], + ["spec:consumers.spec-studio.publisher", "constrainedBy", "spec:extraction.determinism"], + [ + "spec:consumers.spec-studio.publisher", + "decidedBy", + "spec:decisions.shipped-projections-frozen", + "the Studio publishes to a root of its own beside the four frozen ones", + ], + ["spec:consumers.spec-studio.page-elements", "refines", "spec:consumers.spec-studio.components"], + ["spec:consumers.spec-studio.page-elements", "dependsOn", "spec:consumers.spec-studio.shell"], + ["spec:consumers.spec-studio.page-elements", "dependsOn", "spec:consumers.spec-studio.search"], + ["spec:consumers.spec-studio.page-elements", "decidedBy", "spec:decisions.studio-web-components"], + [ + "spec:validation.corpus-rules.copies-contain-changes", + "refines", + "spec:validation.corpus-rules", + ], + [ + "spec:validation.corpus-rules.copies-contain-changes", + "verifies", + "spec:validation.corpus-rules", + null, + ["copiesPerFile", "namedOrder"], + ], + ["spec:validation.corpus-rules.error-finding-fails", "refines", "spec:validation.corpus-rules"], + [ + "spec:validation.corpus-rules.error-finding-fails", + "verifies", + "spec:validation.corpus-rules", + null, + ["exitCode"], + ], + [ + "spec:validation.corpus-rules.malformed-finding-fails", + "refines", + "spec:validation.corpus-rules", + ], + [ + "spec:validation.corpus-rules.malformed-finding-fails", + "verifies", + "spec:validation.corpus-rules", + null, + ["brokenRuleFails"], + ], + [ + "spec:validation.corpus-rules.warning-finding-informs", + "refines", + "spec:validation.corpus-rules", + ], + [ + "spec:validation.corpus-rules.warning-finding-informs", + "verifies", + "spec:validation.corpus-rules", + null, + ["exitCode"], + ], + [ + "spec:validation.architecture-constraints.forbidden-layer-pair", + "refines", + "spec:validation.architecture-constraints", + ], + [ + "spec:validation.architecture-constraints.forbidden-layer-pair", + "verifies", + "spec:validation.architecture-constraints", + null, + ["walk"], + ], + [ + "spec:validation.architecture-constraints.isolated-context-as-rule", + "refines", + "spec:validation.architecture-constraints", + ], + [ + "spec:validation.architecture-constraints.isolated-context-as-rule", + "verifies", + "spec:validation.architecture-constraints", + null, + ["walk", "walkAsRule"], + ], + [ + "spec:validation.architecture-constraints.unstated-layer-unclassified", + "refines", + "spec:validation.architecture-constraints", + ], + [ + "spec:validation.architecture-constraints.unstated-layer-unclassified", + "verifies", + "spec:validation.architecture-constraints", + null, + ["unclassified"], + ], + [ + "spec:model.planned-architecture.unrealized-design", + "refines", + "spec:model.planned-architecture", + ], + [ + "spec:model.planned-architecture.unrealized-design", + "verifies", + "spec:model.planned-architecture", + ], + [ + "spec:model.planned-architecture.differing-layer-and-uses", + "refines", + "spec:model.planned-architecture", + ], + [ + "spec:model.planned-architecture.differing-layer-and-uses", + "verifies", + "spec:model.planned-architecture", + ], + ["spec:model.planned-architecture.matching-design", "refines", "spec:model.planned-architecture"], + [ + "spec:model.planned-architecture.matching-design", + "verifies", + "spec:model.planned-architecture", + ], ] as const; diff --git a/test/self-hosting-oracle/extraction.ts b/test/self-hosting-oracle/extraction.ts index 630f796d..4754e834 100644 --- a/test/self-hosting-oracle/extraction.ts +++ b/test/self-hosting-oracle/extraction.ts @@ -550,6 +550,7 @@ export const extractionSpecs = [ behavior: { rules: [ "The core plans every contract step in authored order and runs it against the world the caller hands in; creating a fresh world per example is the adapter's lifecycle, never the core's.", + "Once the Vitest adapter creates a world, it awaits the optional `after` hook whether the steps pass or fail. A step failure stays the test's failure and an error from `after` is then discarded; when the steps pass, an error from `after` fails the test. A world factory that throws creates no world, so `after` does not run.", "Duplicate step text within one example binds one handler, and every occurrence runs it; an occurrence that binds a slot to a different value is a conflicting re-binding, named at generation, and every occurrence runs with the first binding, because an example is one point.", "A red step names itself before the assertion detail: the failure message leads with the step's natural reading — the Spec's own words with bound values inlined — and the original error is preserved, carried as `cause` when it cannot be re-messaged, and wrapped when the thrown value is not an error.", "A missing or stale step handler is a compile-time refusal rather than a silent skip: the bindings type covers every step and only the steps, so spec-side drift fails the typecheck instead of the run.", @@ -570,8 +571,9 @@ export const extractionSpecs = [ }, ruleKeys: { contractOrder: "rules[0]", - oneHandlerPerStep: "rules[1]", - redStepNamesItself: "rules[2]", + worldCleanup: "rules[1]", + oneHandlerPerStep: "rules[2]", + redStepNamesItself: "rules[3]", }, }, }, diff --git a/test/self-hosting-oracle/index.ts b/test/self-hosting-oracle/index.ts index 5d5f3bce..c40900b3 100644 --- a/test/self-hosting-oracle/index.ts +++ b/test/self-hosting-oracle/index.ts @@ -49,9 +49,9 @@ export const expectedSpecs: readonly ExpectedSpec[] = specFamilies.flatMap( (family) => family.specs, ); -// The owner retains the honesty gap on spec:model.spec-sections and six intended prose-mention -// pairs with no declared relation either way. Question links name related designs without -// asserting a dependency; each subject and relatedId pair remains visible as a warning. +// Five intended prose-mention pairs have no declared relation either way. The owner retains +// the honesty gap on spec:model.spec-sections. The built corpus-check and Studio designs +// have verifier bindings, so their former gaps are absent. export const expectedWarnings = [ { validatorId: "conformance/prose-mentions", @@ -60,13 +60,6 @@ export const expectedWarnings = [ subjectId: "spec:consumers.obsidian-vault.iteration", relatedId: "spec:consumers.validate-watch", }, - { - validatorId: "conformance/prose-mentions", - family: "conformance", - severity: "warning", - subjectId: "spec:consumers.obsidian-vault", - relatedId: "spec:consumers.spec-studio", - }, { validatorId: "conformance/prose-mentions", family: "conformance", @@ -107,6 +100,7 @@ export type { ExpectedAnchor } from "./anchors.js"; export { expectedAnchors, expectedBindingEdges, expectedCommentAnchorIds } from "./anchors.js"; export { expectedDeclaredRelations } from "./declared-relations.js"; export { + expectedCorpusChecksPackMembers, expectedDesignIterationPackMembers, expectedObsidianVaultPackMembers, expectedDesignManagementPackMembers, diff --git a/test/self-hosting-oracle/model.ts b/test/self-hosting-oracle/model.ts index ff1c3a92..858021ed 100644 --- a/test/self-hosting-oracle/model.ts +++ b/test/self-hosting-oracle/model.ts @@ -648,11 +648,11 @@ export const modelSpecs = [ id: "spec:model.planned-architecture", specKind: "model", altitude: "feature", - readiness: "defined", + readiness: "ready", file: "specs/model/planned-architecture.sdp.md", title: "Components are designed ahead of their code", narrative: - "The original design declared a component before its code, as a Component node with a layer, a bounded context and a capability, kept in an architecture folder and reconciled with its code anchors and the Specs (`docs/lineage/v0-design/03-graph-metamodel.md` §2.5; `04-authoring-surfaces.md` §7). Its runtime chapter separated what a unit requires from the provider that supplies it, the provider a composition root selects and the one a test puts in its place, and gave one mechanism the ownership of runtime composition (`05-runtime-anchors.md` §1, §3.3, §7).\n\nThe Protocol records the architecture the code realizes. A `component:` anchor states its layer and context, and anchored `memberOf` and `uses` edges join the code units. Under the architectural annotation decision, a component no code realizes is a Spec, and the graph holds no node for it until an anchor exists. The first adopter states its component list and its context rule in prose (`libar-platform/design/PLAN.md` §2.1) and checks context isolation over anchored `uses` in its own script (`libar-platform/design/tools/check.py`). The owner ruled that the architectural annotation decision stands: a component designed before its code is a component design Spec, and the `component:` anchor that later realizes it satisfies that Spec, which is the join a comparison reads.", + "The original design declared a component before its code, as a Component node with a layer, a bounded context and a capability, kept in an architecture folder and reconciled with its code anchors and the Specs (`docs/lineage/v0-design/03-graph-metamodel.md` §2.5; `04-authoring-surfaces.md` §7). Its runtime chapter separated what a unit requires from the provider that supplies it, the provider a composition root selects and the one a test puts in its place, and gave one mechanism the ownership of runtime composition (`05-runtime-anchors.md` §1, §3.3, §7).\n\nThe Protocol records the architecture the code realizes. A `component:` anchor states its layer and context, and anchored `memberOf` and `uses` edges join the code units. Under the architectural annotation decision, a component no code realizes is a Spec, and the graph holds no node for it until an anchor exists. The first adopter states its component list and its context rule in prose (`libar-platform/design/PLAN.md` §2.1) and checks context isolation over anchored `uses` in its own script (`libar-platform/design/tools/check.py`). The owner ruled that the architectural annotation decision stands: a component designed before its code is a component design Spec, and the `component:` anchor that later realizes it satisfies that Spec, which is the join a comparison reads.\n\nChoosing a provider stays prose. The original's distinctions between the interface a unit requires, the providers that supply it, the one a composition root selects and the one a test substitutes help a reader, and none of them needs graph structure beyond `uses` to be compared. Keeping one composition mechanism per application is a corpus's own architecture rule when the corpus wants it, stated in a `constraint` Spec and checked by its own corpus rule. A component design names its component with the keys a component anchor uses, so the comparison reads the same words on both sides, and a key the design leaves out is not compared, so a design states only what it has decided; a design that has decided its component uses nothing says `none`, which a left-out key cannot say.", sections: { intent: { problem: @@ -662,46 +662,49 @@ export const modelSpecs = [ risks: [ "A planned component kept apart from its realization is a second place for the component to drift, which is why the architectural annotation decision refused a separate architecture file and why the design lives in a Spec the realizing anchor satisfies.", ], - openQuestions: [ - { - question: - "The original kept apart the interface a unit requires, the providers that supply it, the provider a composition root selects and the one a test substitutes, for Fastify, Effect, Awilix and plain factories alike. Does a component design state these without naming a framework, and does any of them earn graph structure beyond `uses`?", - blocking: false, - key: "providerSelection", - decider: "owner", - }, - { - question: - "Is the original's one-runtime-truth rule, one composition mechanism per application, a Protocol rule or a corpus's own architecture rule?", - blocking: false, - key: "oneRuntimeOwner", - decider: "owner", - }, - { - question: - "How is a component design selected, what value form does its `uses` entry take, and what rows does the realization comparison return for an unrealized design and for a layer, context or `uses` that differs?", - blocking: false, - key: "comparisonShape", - decider: "owner", - provisional: - "a Spec is a component design when its Design `id` value, bare or in the code span that opens it, is a `component:` id, as the curation report reads it", - }, - ], }, model: { terms: { "component design": - "A Spec that states a component the code does not yet realize and what it is for. Its Design entries are keyed as the anchor's attributes are named: `id` for the planned `component:` anchor's own id, `layer`, `context`, `uses` for the components it will use, and optionally `role`. The keys are a convention a recipe reads; the Protocol closes no Design shape, and the Spec confers nothing and mints no code unit.", + "A Spec that states a component the code does not yet realize and what it is for. Its Design entries are keyed as the anchor's attributes are named: `id` for the planned `component:` anchor's own id, `layer`, `context`, `uses` for the components it will use, and optionally `role`. The keys are a convention recipes read; the Protocol closes no Design shape, and the Spec confers nothing and mints no code unit.", "planned component": "The component a component design names. It stays intent until a `component:` anchor realizes it, and that anchor satisfies the component design; the graph holds no component node before the anchor exists.", "realization comparison": - "A catalog recipe that reads each component design beside the `component:` anchor that satisfies it, through the component data recipe 32 joins, and lists a design no anchor satisfies yet, a layer or context that differs, and a `uses` named on one side only. It reports and refuses nothing.", + "Recipe 43, planned against realized components, which reads each component design beside the `component:` anchor that satisfies it, resolving members through `memberOf` as recipe 32 does, and lists a design no anchor satisfies yet, a layer, context or role that differs, and a `uses` named on one side only. It reports and refuses nothing.", provider: - "A code unit that supplies an interface another unit requires; a composition selects one provider for each requirement, and a test may select another.", + "A code unit that supplies an interface another unit requires; a composition selects one provider for each requirement, and a test may select another. A component design names its providers and that selection in prose, and none of them earns graph structure beyond `uses`.", + }, + }, + design: { + componentDesign: + "a Spec is a component design when its Design `id` value, bare or in the code span that opens it, is a `component:` id; the curation report and the realization comparison select designs this way, and no validator reads the convention.", + designKeys: + "the comparison reads five Design keys, `id`, `layer`, `context`, `role` and `uses`, and no other; `layer` takes one of the five layers, `context` and `role` one token on the role grammar, and `uses` either `none` for the empty list or `component:` ids separated by commas, each trimmed, read as the set of distinct ids; each value is read bare when it is the whole value, or from the code span that opens it, with prose after the span free to say why.", + comparisonRecipe: + "recipe 43, planned against realized components, takes no parameter, so any parameter name returns `{ unknownParams, accepts: [] }` alone, as every catalog recipe answers a name it does not take; otherwise it returns `{ totals, unrealized, differing, unreadable }`, where `totals` is `{ designs, realized, matching, unrealized, differing, unreadable }`, `matching` counts the realized designs with neither a differing row nor an unreadable row, and `differing` counts rows.", + realizedWhen: + "a design is realized when its `component:` CodeNode is anchored and has an anchored `satisfies` edge to the design, as the curation report reads it; a design whose component exists without that edge stays unrealized, with `anchorExists: true`.", + comparedFields: + "for a realized design, each of `layer`, `context`, `role` and `uses` the design states is compared with the anchored component, and a key the design leaves out is not compared; the realized `uses` are the components other than its own that an anchored `uses` edge from the component or one of its members reaches, each end resolved to its component as recipe 32 resolves it.", + comparisonRows: + "`unrealized` rows are `{ spec, componentId, anchorExists, layer, context, role, uses }` with the planned values, null when unstated, and `uses` an empty list when the design states `none`; `differing` rows are `{ spec, componentId, field, planned, realized }`, one for each differing field, where `layer`, `context` and `role` carry the two values or null and `uses` carries the two sorted lists of components named on that side only; `unreadable` rows are `{ spec, key, value }` for a value outside its key's form, and that key is not compared; rows sort by `spec`, then by `field` or `key`.", + }, + behavior: { + exampleSpace: { + given: [ + 'a component design for {componentId:string} that plans the layer {plannedLayer:"domain"|"application"} and uses {plannedUses:string}', + 'the component anchor for that design {anchor:"is absent"|"satisfies the design"}', + 'that anchor states the layer {realizedLayer:"domain"|"application"|"absent"} and its member uses {realizedUses:string}', + ], + when: ["the realization comparison runs"], + then: [ + "the comparison lists {unrealized:number} unrealized designs, {matching:number} matching designs and {differing:number} differing rows", + "the differing fields are {fields:string}", + ], }, }, }, - deliveryFacts: [], + deliveryFacts: ["implemented", "has-verifier"], }, { id: "spec:model.open-question-fields", @@ -906,4 +909,109 @@ export const modelSpecs = [ }, deliveryFacts: [], }, + { + id: "spec:model.planned-architecture.unrealized-design", + specKind: "example", + altitude: "story", + readiness: "ready", + file: "specs/model/planned-architecture.unrealized-design.sdp.md", + title: "A component design no anchor satisfies is listed as unrealized", + narrative: null, + sections: { + intent: { + outcome: + "Execute the realization comparison on one component design whose component no anchor realizes yet.", + assumptions: [ + "The world writes a probe root holding one story-altitude model Spec stating `idea` with an Intent outcome, one Model term and a Design section whose `id`, `layer` and `uses` entries carry the slots' planned values, bare. Beside it the root holds comment-form anchors for `component:probe.kernel` and `component:probe.audit`, each with one `impl:` member. The world runs the comparison's shipped recipe file through `sdp q --json`; the differing fields are the `field` values of the `differing` rows joined by a comma and a space, in answer order, and an empty string names none.", + ], + }, + behavior: { + examples: [ + { + given: [ + 'a component design for {componentId: "component:probe.history"} that plans the layer {plannedLayer: "domain"} and uses {plannedUses: "component:probe.kernel"}', + 'the component anchor for that design {anchor: "is absent"}', + 'that anchor states the layer {realizedLayer: "absent"} and its member uses {realizedUses: ""}', + ], + when: ["the realization comparison runs"], + then: [ + "the comparison lists {unrealized: 1} unrealized designs, {matching: 0} matching designs and {differing: 0} differing rows", + 'the differing fields are {fields: ""}', + ], + }, + ], + }, + }, + deliveryFacts: ["has-verifier"], + }, + { + id: "spec:model.planned-architecture.differing-layer-and-uses", + specKind: "example", + altitude: "story", + readiness: "ready", + file: "specs/model/planned-architecture.differing-layer-and-uses.sdp.md", + title: "A realized component that differs from its design gives one row per field", + narrative: null, + sections: { + intent: { + outcome: + "Execute the realization comparison on one realized component whose layer and uses differ from its design.", + assumptions: [ + "The world writes the probe root of the unrealized-design example and adds a comment-form anchor for the planned component that satisfies the design Spec and states the realized layer, with one `impl:` member whose anchor `uses` the member of the component the realized-uses slot names.", + ], + }, + behavior: { + examples: [ + { + given: [ + 'a component design for {componentId: "component:probe.history"} that plans the layer {plannedLayer: "domain"} and uses {plannedUses: "component:probe.kernel"}', + 'the component anchor for that design {anchor: "satisfies the design"}', + 'that anchor states the layer {realizedLayer: "application"} and its member uses {realizedUses: "component:probe.audit"}', + ], + when: ["the realization comparison runs"], + then: [ + "the comparison lists {unrealized: 0} unrealized designs, {matching: 0} matching designs and {differing: 2} differing rows", + 'the differing fields are {fields: "layer, uses"}', + ], + }, + ], + }, + }, + deliveryFacts: ["has-verifier"], + }, + { + id: "spec:model.planned-architecture.matching-design", + specKind: "example", + altitude: "story", + readiness: "ready", + file: "specs/model/planned-architecture.matching-design.sdp.md", + title: "A realized component that matches its design gives no row", + narrative: null, + sections: { + intent: { + outcome: + "Execute the realization comparison on one realized component whose layer and uses match its design.", + assumptions: [ + "The world writes the probe root of the differing example, with the realized layer and uses the slots give.", + ], + }, + behavior: { + examples: [ + { + given: [ + 'a component design for {componentId: "component:probe.history"} that plans the layer {plannedLayer: "domain"} and uses {plannedUses: "component:probe.kernel"}', + 'the component anchor for that design {anchor: "satisfies the design"}', + 'that anchor states the layer {realizedLayer: "domain"} and its member uses {realizedUses: "component:probe.kernel"}', + ], + when: ["the realization comparison runs"], + then: [ + "the comparison lists {unrealized: 0} unrealized designs, {matching: 1} matching designs and {differing: 0} differing rows", + 'the differing fields are {fields: ""}', + ], + }, + ], + }, + }, + deliveryFacts: ["has-verifier"], + }, ] as const; diff --git a/test/self-hosting-oracle/pack-members.ts b/test/self-hosting-oracle/pack-members.ts index 69a1d17d..c5d89b4a 100644 --- a/test/self-hosting-oracle/pack-members.ts +++ b/test/self-hosting-oracle/pack-members.ts @@ -69,6 +69,9 @@ export const expectedPackMembers = [ "spec:model.open-question-keys.repeated-key-refused", "spec:model.design-maturity", "spec:model.planned-architecture", + "spec:model.planned-architecture.unrealized-design", + "spec:model.planned-architecture.differing-layer-and-uses", + "spec:model.planned-architecture.matching-design", "spec:model.open-question-fields", "spec:model.rule-keys", "spec:model.relation-reasons", @@ -148,7 +151,14 @@ export const expectedPackMembers = [ "spec:validation.next-rung-floor.blocking-question", "spec:validation.superseded-decision-signal", "spec:validation.architecture-constraints", + "spec:validation.architecture-constraints.forbidden-layer-pair", + "spec:validation.architecture-constraints.isolated-context-as-rule", + "spec:validation.architecture-constraints.unstated-layer-unclassified", "spec:validation.corpus-rules", + "spec:validation.corpus-rules.copies-contain-changes", + "spec:validation.corpus-rules.error-finding-fails", + "spec:validation.corpus-rules.malformed-finding-fails", + "spec:validation.corpus-rules.warning-finding-informs", "spec:consumers.projections-model", "spec:consumers.projections-model.roadmap-views", "spec:consumers.impact-graph", @@ -191,10 +201,16 @@ export const expectedPackMembers = [ "spec:consumers.wholesale-view-rewrite.build-invalidates-view", "spec:consumers.agent-surface.address-and-cycle-recipes", "spec:consumers.spec-studio", + "spec:consumers.spec-studio.index-page", + "spec:consumers.spec-studio.pack-page", + "spec:consumers.spec-studio.publisher", + "spec:consumers.spec-studio.search", + "spec:consumers.spec-studio.page-elements", "spec:consumers.spec-studio.data", "spec:consumers.spec-studio.shell", "spec:consumers.spec-studio.lenses", "spec:consumers.spec-studio.spec-page", + "spec:consumers.spec-studio.spec-page.core", "spec:consumers.spec-studio.verification-panels", "spec:consumers.spec-studio.intent-panel", "spec:consumers.spec-studio.components", @@ -268,13 +284,19 @@ export const expectedStudioPackMembers = [ "spec:consumers.spec-studio", "spec:decisions.studio-html-surface", "spec:consumers.spec-studio.data", + "spec:consumers.spec-studio.publisher", "spec:consumers.spec-studio.shell", + "spec:consumers.spec-studio.search", "spec:consumers.spec-studio.lenses", + "spec:consumers.spec-studio.index-page", + "spec:consumers.spec-studio.pack-page", "spec:consumers.spec-studio.spec-page", + "spec:consumers.spec-studio.spec-page.core", "spec:consumers.spec-studio.verification-panels", "spec:consumers.spec-studio.intent-panel", "spec:consumers.spec-studio.components", "spec:decisions.studio-web-components", + "spec:consumers.spec-studio.page-elements", "spec:consumers.spec-studio.responsive", "spec:consumers.spec-studio.sharing", ] as const; @@ -331,6 +353,22 @@ export const expectedObsidianVaultPackMembers = [ "spec:model.term-addresses", ] as const; +export const expectedCorpusChecksPackMembers = [ + "spec:validation.corpus-rules", + "spec:validation.corpus-rules.error-finding-fails", + "spec:validation.corpus-rules.warning-finding-informs", + "spec:validation.corpus-rules.malformed-finding-fails", + "spec:validation.corpus-rules.copies-contain-changes", + "spec:validation.architecture-constraints", + "spec:validation.architecture-constraints.forbidden-layer-pair", + "spec:validation.architecture-constraints.isolated-context-as-rule", + "spec:validation.architecture-constraints.unstated-layer-unclassified", + "spec:model.planned-architecture", + "spec:model.planned-architecture.unrealized-design", + "spec:model.planned-architecture.differing-layer-and-uses", + "spec:model.planned-architecture.matching-design", +] as const; + export const expectedPacks = [ { id: "pack:spec-studio-v1", @@ -338,11 +376,21 @@ export const expectedPacks = [ claim: "declared", title: "Spec Studio", framing: - "The Spec Studio of the original design, `docs/lineage/v0-design/07-spec-studio-and-projections.md` sections 1 to 3, 11 and 12, in the order a reviewer reads it: what the Studio is and why it renders HTML, the data it reads, its shell and lenses, the Spec page and its panels, the composing panel, its custom elements, its phone layout, and how a build is shared. Recipe 27, references into a design, shows each member's code bindings and the implemented Specs it builds on.", + "The Spec Studio of the original design, `docs/lineage/v0-design/07-spec-studio-and-projections.md` sections 1 to 3, 11 and 12, in the order a reviewer reads it: what the Studio is and why it renders HTML, the data it reads and the verb that publishes it, its shell and search, its lenses and the index, the Pack page, the Spec page with its core and its panels, the composing panel, its custom elements, its phone layout, and how a build is shared. The publisher, the shell, search, the index, the Pack page, the Spec page's core and the page elements make up the static page set; the other panels, the other lenses and the composing panel build on it. Recipe 27, references into a design, shows each member's code bindings and the implemented Specs it builds on.", file: "specs/consumers/spec-studio.pack.sdp.md", members: expectedStudioPackMembers, modelRefs: ["spec:consumers.projections-model"], }, + { + id: "pack:corpus-checks-v1", + nodeType: "Pack", + claim: "declared", + title: "Corpus checks", + framing: + "This Pack groups what a corpus needs to check its own policy on the one validation path, in reading order. Corpus rules are the corpus's own graph bodies that `sdp validate --rules` runs and reports apart from the Protocol's findings. The architecture walk is a catalog recipe a corpus runs as one of those rules, over the layers, contexts and uses its anchors state. Component designs state the components a design adds before their code exists, and a catalog recipe compares them with the components the anchors later realize. Each Spec is followed by the examples that will verify it.", + file: "specs/corpus-checks.pack.sdp.md", + members: expectedCorpusChecksPackMembers, + }, { id: "pack:design-iteration-v1", nodeType: "Pack", diff --git a/test/self-hosting-oracle/structural-edges.ts b/test/self-hosting-oracle/structural-edges.ts index 28edcf37..1ef318b6 100644 --- a/test/self-hosting-oracle/structural-edges.ts +++ b/test/self-hosting-oracle/structural-edges.ts @@ -5,6 +5,7 @@ // re-transcribed. export const expectedComponentIds = [ + "component:protocol.studio-elements", "component:protocol.adapters", "component:protocol.cli", "component:protocol.codegen", @@ -24,6 +25,8 @@ export const expectedComponentIds = [ // outside the engine source seams and therefore have no truthful owner among the accepted // component set. export const structuralMembershipExceptions = [ + "impl:protocol.architecture-walk-recipe", + "impl:protocol.planned-components-recipe", "impl:protocol.authoring-on-ramp", "impl:protocol.register-recipes", "impl:protocol.adopter-on-ramp", @@ -47,6 +50,106 @@ export const structuralMembershipExceptions = [ ] as const; export const acceptedArchitecturalUnits = [ + { + unit: "src/cli/validate-view-command.ts#runValidateRules", + anchorId: "impl:protocol.corpus-rules", + componentId: "component:protocol.cli", + }, + { + unit: "src/projections/studio/data.ts#packTotals", + anchorId: "impl:protocol.studio-data", + componentId: "component:protocol.projections", + }, + { + unit: "src/projections/studio/paths.ts#pagePath", + anchorId: "impl:protocol.studio-paths", + componentId: "component:protocol.projections", + }, + { + unit: "src/projections/studio/publish.ts#studioFiles", + anchorId: "impl:protocol.studio-publish", + componentId: "component:protocol.projections", + }, + { + unit: "src/cli/studio-command.ts#runStudio", + anchorId: "impl:protocol.studio-command", + componentId: "component:protocol.cli", + }, + { + unit: "src/cli/build-command.ts#runBuild", + anchorId: "impl:protocol.studio-build-invalidation", + componentId: "component:protocol.cli", + }, + { + unit: "src/reader/concept-match.ts#matchConcept", + anchorId: "impl:protocol.studio-concept-match", + componentId: "component:protocol.reader", + }, + { + unit: "src/studio-elements/copy.ts#registerCopy", + anchorId: "impl:protocol.studio-copy", + componentId: "component:protocol.studio-elements", + }, + { + unit: "src/studio-elements/expander.ts#registerExpander", + anchorId: "impl:protocol.studio-expander", + componentId: "component:protocol.studio-elements", + }, + { + unit: "src/studio-elements/filter.ts#registerFilter", + anchorId: "impl:protocol.studio-filter", + componentId: "component:protocol.studio-elements", + }, + { + unit: "src/studio-elements/search.ts#registerSearch", + anchorId: "impl:protocol.studio-search-form", + componentId: "component:protocol.studio-elements", + }, + { + unit: "src/studio-elements/search-results.ts#searchRecords", + anchorId: "impl:protocol.studio-search-results", + componentId: "component:protocol.studio-elements", + }, + { + unit: "src/studio-elements/stamp.ts#registerStamp", + anchorId: "impl:protocol.studio-stamp", + componentId: "component:protocol.studio-elements", + }, + { + unit: "src/projections/studio/pages/frame.ts#frame", + anchorId: "impl:protocol.studio-frame", + componentId: "component:protocol.projections", + }, + { + unit: "src/projections/studio/pages/text.ts#text", + anchorId: "impl:protocol.studio-text", + componentId: "component:protocol.projections", + }, + { + unit: "src/projections/studio/pages/index-page.ts#indexPage", + anchorId: "impl:protocol.studio-index-page", + componentId: "component:protocol.projections", + }, + { + unit: "src/projections/studio/pages/pack-page.ts#packPage", + anchorId: "impl:protocol.studio-pack-page", + componentId: "component:protocol.projections", + }, + { + unit: "src/projections/studio/pages/spec-page.ts#specPage", + anchorId: "impl:protocol.studio-spec-page", + componentId: "component:protocol.projections", + }, + { + unit: "src/projections/studio/pages/search-page.ts#searchPage", + anchorId: "impl:protocol.studio-search-page", + componentId: "component:protocol.projections", + }, + { + unit: "src/projections/studio/pages/code-spans.ts#mapCodeSpans", + anchorId: "impl:protocol.studio-code-spans", + componentId: "component:protocol.projections", + }, { unit: "src/extract/serialize.ts#canonicalDynamicSection", anchorId: "impl:protocol.open-section-order", @@ -503,7 +606,7 @@ export const acceptedArchitecturalUnits = [ componentId: "component:protocol.projections", }, { - unit: "src/cli/q-command.ts#readParams", + unit: "src/cli/q-command.ts#parseQueryArgs", anchorId: "impl:protocol.query-params", componentId: "component:protocol.cli", }, @@ -573,7 +676,7 @@ export const acceptedArchitecturalUnits = [ componentId: "component:protocol.cli", }, { - unit: "src/cli/validate-view-command.ts#runValidate", + unit: "src/cli/validate-view-command.ts#writeProtocolValidation", anchorId: "impl:protocol.validate-summary", componentId: "component:protocol.cli", }, @@ -594,6 +697,24 @@ export const expectedMemberOfEdges = acceptedArchitecturalUnits // impl:protocol.validate-watch, and joined the accepted units above. Plan 43 removed the // unused targetsForRelationType helper after the emitter switched to full relation objects. export const coarseGrainCoverage = [ + { + unit: "src/cli/params.ts#mergeParams", + coveredBy: "impl:protocol.query-params", + componentId: "component:protocol.cli", + rationale: "src/cli/q-command.ts#parseQueryArgs value-calls mergeParams", + }, + { + unit: "src/studio-elements/dom.ts#button", + coveredBy: "impl:protocol.studio-search-results", + componentId: "component:protocol.studio-elements", + rationale: "src/studio-elements/search-results.ts#registerSearchResults value-calls button", + }, + { + unit: "src/studio-elements/dom.ts#liveOutput", + coveredBy: "impl:protocol.studio-search-results", + componentId: "component:protocol.studio-elements", + rationale: "src/studio-elements/search-results.ts#registerSearchResults value-calls liveOutput", + }, { unit: "src/import/markdown-fidelity.ts#assertMarkdownEmissionFidelity", coveredBy: "impl:protocol.sdp-import-markdown-emit", @@ -632,6 +753,8 @@ export const coarseGrainCoverage = [ // confer no edge. The convention is stated in spec:protocol.structural-self-binding. // re-measured under plan 41 export const expectedUsesEdges = [ + ["component:protocol.studio-elements", "component:protocol.reader"], + ["component:protocol.studio-elements", "component:protocol.projections"], ["impl:protocol.example-runner-adapter", "impl:protocol.example-runner"], ["impl:protocol.authored-honesty-delivery-facts", "impl:protocol.delivery-facts"], ["impl:protocol.reader", "impl:protocol.delivery-facts"], diff --git a/test/self-hosting-oracle/validation.ts b/test/self-hosting-oracle/validation.ts index 7a1a1ba7..6997ae2d 100644 --- a/test/self-hosting-oracle/validation.ts +++ b/test/self-hosting-oracle/validation.ts @@ -1272,7 +1272,7 @@ export const validationSpecs = [ 'A token whose Spec exists and whose entry address names no key of that `design` or `ui` section, names `description` there, names a question key no open question of that Spec carries, or names a rule key that Spec\'s `behavior.ruleKeys` does not carry, is an error with the message `Mention in "<spec>" at <path> points to missing entry "<section>.<key>" of "<target>".`', "Errors are reported once per scanning Spec, text path, and distinct token; `subjectId` is the scanning Spec, `relatedId` the token as written, `path` the text path, and `file` the Spec's carrier. The path is `narrative` or the section path in the mention audit's form, such as `intent.openQuestions[0].question`, `behavior.rules[2]`, `design.step2`, or `model.terms.<term>`.", 'A target Spec that exists and shares no declared `refines`, `dependsOn`, `constrainedBy`, `decidedBy`, `verifies`, or `supersedes` edge with the scanning Spec, in either direction, is one warning per scanning Spec and target Spec pair, counting every location where any token names that Spec or one of its entries. The message is `Mention of "<target>" in "<spec>" at <n> locations, first at <path>, with no declared relation between them; informative only. Declare the relation that fits, or leave the mention as prose and let the warning stand when none does.` with `<n>` the count of distinct paths, written `1 location` when it is one, and `<path>` the first in scan order; `subjectId` is the scanning Spec, `relatedId` the target Spec id, `path` that first path, and `file` the Spec\'s carrier. An address on the scanning Spec\'s own id is checked for its entry and never warned.', - "A mention error or pair warning carries `line` when the location table records the corresponding text path; a question link uses the `question[<i>].links` row, and an unrecorded line is omitted.", + "A mention error or pair warning carries `line` when the location table records the corresponding text path; `behavior.rules[<i>]` uses `rule[<i>]`, `behavior.flows[<i>]` uses `flow[<i>]`, an open question's text uses `question[<i>]`, and a question field uses `question[<i>].<field>`, with each question link using the `links` row. Entries of Rule, Workflow and Contract use the same behavior paths as entries of Behavior. An unrecorded line is omitted.", "The warning is honest when it stands: the six relations are typed, a mention that none of them describes is lawful prose, and no author declares a relation to silence the report. Warnings never fail `sdp validate`; the mention audit recipe reports every location of every pair as data and stays the way to list them at once.", ], }, @@ -1670,118 +1670,406 @@ export const validationSpecs = [ id: "spec:validation.architecture-constraints", specKind: "rule", altitude: "feature", - readiness: "defined", + readiness: "ready", file: "specs/validation/architecture-constraints.sdp.md", title: "Declared architecture rules are checked against anchored structure", narrative: - "The original design gave architecture a validation tier of its own. Its rules said that only adapter components depend on external systems, that domain code imports no infrastructure and that layers depend in one direction; dependency-cruiser, architecture tests and lint rules ran them, and their findings joined the one report (`docs/lineage/v0-design/06-extraction-and-validation.md` §6.4). Since the architectural annotation decision, the data those rules read is in the graph with no inference: a component anchor states its layer and its context, and `uses` and `memberOf` edges are anchored. The same decision keeps the check out of the Protocol. A negative architecture constraint stays declared intent in a Spec, and a corpus that wants it enforced checks its declared `uses`, `layer` and `context` in its own gate.\n\nThe first adopter checks that a component in its `platform` context has no `uses` edge to a component in another context, resolving each end through `memberOf`. Architecture crossings, recipe 32, reports differences in context or layer and refuses nothing. The architectural annotation decision keeps architecture checks in the corpus's own gate, and the Protocol supplies the reusable graph walk.", + "The original design gave architecture a validation tier of its own. Its rules said that only adapter components depend on external systems, that domain code imports no infrastructure and that layers depend in one direction; dependency-cruiser, architecture tests and lint rules ran them, and their findings joined the one report (`docs/lineage/v0-design/06-extraction-and-validation.md` §6.4). Since the architectural annotation decision, the data those rules read is in the graph with no inference: a component anchor states its layer and its context, and `uses` and `memberOf` edges are anchored. The same decision keeps the check out of the Protocol. A negative architecture constraint stays declared intent in a Spec, and a corpus that wants it enforced checks its declared `uses`, `layer` and `context` in its own gate.\n\nThe first adopter checks that a component in its `platform` context has no `uses` edge to a component in another context, resolving each end through `memberOf`. Architecture crossings, recipe 32, reports differences in context or layer and refuses nothing. The architectural annotation decision keeps architecture checks in the corpus's own gate, and the Protocol supplies the reusable graph walk.\n\nThe walk returns the edges a rule forbids as corpus findings, so one body answers a reader through `sdp q` and fails a gate when the corpus names it as a rules file; the first adopter's isolation check becomes the walk named by `sdp validate --rules` with the isolated context `platform`, and none of its own code. An edge the walk cannot decide, because a component states no layer or context or an end belongs to no component, is listed apart: a breach the anchors cannot show is a curation gap, not a finding.", sections: { intent: { problem: "A corpus that states an architecture rule in a Spec cannot have it checked by the Protocol, so each adopter writes the same walk over `uses`, `memberOf`, `layer` and `context` in its own gate.", outcome: "Check a corpus's declared architecture rules, such as a forbidden uses direction or context isolation, against the layers, contexts and uses its anchors state.", - openQuestions: [ - { - question: - "Do corpus rules need to require a component to state a layer or context, or check ports and routes that anchors do not carry, beyond the selected forbidden layer pairs and isolated contexts?", - blocking: false, - key: "ruleForms", - decider: "owner", - provisional: - "the supplied walk checks forbidden layer pairs and isolated contexts; absent attributes are unclassified rather than breaches", - }, - { - question: - "Which pure callable walk serves both the catalog recipe and a loaded corpus rule, and what rule-data and result shapes do they share?", - blocking: false, - key: "walkInvocation", - decider: "owner", - provisional: - "one pure walk takes `params.forbidden`, a list of `{ from, to }` layer pairs, and `params.isolated`, a list of context tokens whose components may have no `uses` edge into another context, either list possibly empty; it returns each forbidden edge as `{ from, to, fromComponent, toComponent, rule }` and lists unclassified edges apart; recipe 32's result stays as it is", - }, - ], }, behavior: { rules: [ "The Protocol's validators never judge a corpus's architecture; an architecture rule runs as one of the corpus's own rules, the corpus's gate decides what fails, and that gate may be the corpus's own invocation of `sdp validate --rules` with the severity the rule states.", "A corpus states each architecture rule as intent in a `constraint` Spec that says what is forbidden and why, and keeps the rule's machine form, its forbidden layer pairs and isolated contexts, as data its corpus rule passes to the walk.", - "The Protocol supplies the walk as a catalog recipe that takes forbidden layer pairs and isolated contexts as recipe parameters, resolves each end of every anchored `uses` edge to its component, directly or through `memberOf`, and returns each edge a rule forbids with both units, both components and the rule.", + "The Protocol supplies the walk as a catalog recipe, recipe 42, that takes forbidden layer pairs and isolated contexts as recipe parameters, resolves each end of every anchored `uses` edge to its component, directly or through `memberOf`, and returns each edge a rule forbids with both units, both components and the rule.", + "The walk returns the edges it forbids as corpus findings in its answer's `findings`, so the shipped recipe file runs unchanged as a rules file under `sdp validate --rules`, at the severity the corpus passes it.", + "The walk checks two forms, forbidden layer pairs and isolated contexts. It requires no component to state a layer or a context and checks no port, route or other fact the anchors do not carry; a corpus that wants such a check writes a rule of its own.", "The walk reads the given rules and anchored structure only; it infers no edge from imports, reads no file path, judges no role and refuses nothing.", - "A component that states no layer or context falls outside a rule that names one, and the walk reports it as unclassified rather than as a breach.", + "A component that states no layer or context falls outside a rule that names one, and the walk lists the edge as unclassified rather than as a breach, as it lists an edge with an end that belongs to no component.", "The corpus binds a discovered TypeScript check site to its constraint Spec through a test anchor; that check invokes the corpus rule, and `has-verifier` states that the check exists while its result stays in the corpus's run.", ], + exampleSpace: { + given: [ + 'a member of a component at the layer {fromLayer:"domain"|"application"} in the context {fromContext:"platform"|"orders"} that uses a member of a component at the layer {toLayer:"infrastructure"|"adapter"|"unstated"} in the context {toContext:"platform"|"orders"}', + ], + when: [ + 'the architecture walk runs {invocation:"through sdp q"|"as a rules file named by sdp validate --rules"} with the forbidden pair domain to infrastructure and the isolated context platform', + ], + then: [ + "the walk reports {findings:number} findings with the rule ids {ruleIds:string}", + "the walk lists {unclassified:number} unclassified edges", + "the command exits {exitCode:number}", + ], + }, ruleKeys: { walk: "rules[2]", + walkAsRule: "rules[3]", + forms: "rules[4]", + unclassified: "rules[6]", }, }, + design: { + walkParams: + 'recipe 42, the architecture walk, takes `params.forbidden`, a list of `{ from, to }` pairs of layers from the closed set; `params.isolated`, a list of context tokens; and `params.severity`, `"error"` by default or `"warning"`; either list may be empty, and a pair or context given twice counts once; a parameter name outside these three returns `{ unknownParams, accepts }` alone, before any other check, as every catalog recipe answers a name it does not take; neither list given, a layer outside the set, a malformed token or another severity returns `{ diagnostics }` with no `findings`; and either answer fails the file when the walk runs as a rules file.', + walkAnswer: + "`{ input: { forbidden, isolated, severity }, totals: { usesEdges, resolved, findings, unclassified }, findings, unclassified }` is the answer when every parameter name is known and every value lawful, where `resolved` counts the `uses` edges whose two ends both resolve to a component; findings sort by `from`, then `to`, then rule id, and unclassified rows by `from`, then `to`; recipe 32's answer stays as it is.", + walkBreach: + "an anchored `uses` edge whose ends resolve to two distinct components breaks the pair `{ from, to }` when the using component states layer `from` and the used one states layer `to`, and breaks the isolation of a context when the using component states that context and the used one states another; two units of one component never break a rule, as recipe 32 never counts them as crossing.", + walkFinding: + "`{ validatorId, subjectId, relatedId, severity, message, from, to, fromComponent, toComponent, rule }`, a corpus finding in the shape `spec:validation.corpus-rules#design.ruleFinding` pins, where `subjectId` is the using unit `from`, `relatedId` the used unit `to`, both components are `{ id, layer, context }` with null for an unstated attribute, and `validatorId` is `forbidden-layer` with `rule: { from, to }` the pair it breaks, or `isolated-context` with `rule: { context }` the isolated context it leaves.", + walkMessages: + "a `forbidden-layer` message reads `<from> uses <to>: layer <from layer> uses layer <to layer> (<fromComponent id> to <toComponent id>), a forbidden pair`, and an `isolated-context` message reads `<from> uses <to>: <fromComponent id> in the isolated context <context> uses <toComponent id> in the context <other context>`.", + walkUnclassified: + '`unclassified` rows `{ from, to, fromComponent, toComponent, missing }` list each anchored `uses` edge the walk cannot decide while a list is non-empty: an edge with an end that belongs to no component, that end\'s component null and `missing: ["component"]`, and an edge between two distinct components where either leaves unstated an attribute a non-empty list reads, `layer` for `forbidden` and `context` for `isolated`, named in `missing`.', + }, }, - deliveryFacts: [], + deliveryFacts: ["implemented", "has-verifier"], }, { id: "spec:validation.corpus-rules", specKind: "behavior", altitude: "feature", - readiness: "defined", + readiness: "ready", file: "specs/validation/corpus-rules.sdp.md", title: "Corpus-authored rules run on the one validation path", narrative: - "The original design let a team add its own rules beside the built-in validators, each with an id, a severity and the whole graph query API, run in the same pass and reported in the same output (`docs/lineage/v0-design/06-extraction-and-validation.md` §6.5). The Protocol has two check families, conformance and honesty, and every validator belongs to one of them. The architectural annotation decision sends a corpus's own rules to the corpus's own gate.\n\nThe first adopter's gate runs `sdp validate` and parses its summary line, then checks its own context isolation, entry addresses, dependency cycles, question keys, prose mentions, cited laws and Pack pages. It also parses carrier frontmatter to read kind, readiness and relations, a second read model that a graph-based corpus-rule runner would remove.", + "The original design let a team add its own rules beside the built-in validators, each with an id, a severity and the whole graph query API, run in the same pass and reported in the same output (`docs/lineage/v0-design/06-extraction-and-validation.md` §6.5). The Protocol has two check families, conformance and honesty, and every validator belongs to one of them. The architectural annotation decision sends a corpus's own rules to the corpus's own gate.\n\nThe first adopter's gate runs `sdp validate` and parses its summary line, then checks its own context isolation, entry addresses, dependency cycles, question keys, prose mentions, cited laws and Pack pages. It also parses carrier frontmatter to read kind, readiness and relations, a second read model that a graph-based corpus-rule runner would remove.\n\nA rules file holds the body `sdp q` evaluates, so a rule tried through `sdp q` runs unchanged under `sdp validate`, and a catalog recipe whose answer carries findings, such as the architecture walk, is a rules file as shipped. The runner reads only the answer's findings, so the recipe keeps the rest of its answer for a reader. Corpus findings print after the Protocol's and are tallied on a line of their own, so a gate that reads the Protocol's summary line reads it unchanged; any error, the Protocol's or the corpus's, exits 1, and the two lines say which. Each rules file receives its own copy of what it reads, so a careless rule cannot change another rule's input and the order of the files changes only the order of the output. Its parameters are its own too: a catalog recipe answers a parameter name it does not take with that name and no findings, so parameters shared by every file would fail the walk on the names another file reads.", sections: { intent: { problem: "A corpus with rules of its own checks them off the one validation path, reading the carriers a second way and reporting in a form no Protocol surface reads.", outcome: "Let a corpus author its own graph rules and have their findings join the one validation report, so no corpus re-parses its carriers to check itself.", - openQuestions: [ + }, + behavior: { + rules: [ + "A corpus rule is a graph body the corpus authors, given `g`, `graph`, `report` and `params` as a recipe body is, and returning findings that each carry a rule id, a subject, a message and a severity.", + "A corpus rule reads the one derived graph, the Protocol's report and the `params` the operator supplies for its file, and no carrier, source file or network; that is the rule author's obligation, because the runner evaluates the body with the trust `sdp q` asks and claims no sandbox.", + "Validation runs corpus rules only from a file the operator names with `sdp validate --rules PATH`, which may repeat, and never from a file it finds in the corpus or in a configuration; it asks the trust `sdp q` already asks, to run code the operator chose.", + "Rules files run one at a time in the order named, each to completion, after the Protocol's validators; every file receives the same Protocol report and none sees another file's findings, so the order changes only the order of the output.", + "Each rules file receives its own copy of the graph and the report, made after the graph is written, and its own `params`, so a body that changes what it receives changes no node, edge, delivery fact or Protocol finding, and nothing another rules file receives.", + "Each rules file receives as `params` only the parameters the operator names after its `--rules` and before the next `--rules`, or `{}` when none follow it, so no file receives a parameter meant for another.", + "A rule id names one check within its rules file; two files may use the same id, and their findings stay apart by the file that reported them.", + "Each corpus finding names its rule and its rules file as its source. The Protocol states none of these rules: a shipped recipe that a corpus names as a rules file checks the rule data the corpus passes it.", + "Corpus findings stand outside the conformance and honesty families, in a section of the output labelled as the corpus's own and tallied on a summary line of their own, so the two families keep their meaning, the guardrail that checks never judge content quality or workflow keeps binding the Protocol's own validators, and no corpus finding adds to, suppresses or changes a Protocol finding or the exit reason it sets.", + "A corpus finding carries severity `error` or `warning`. When the operator names `--rules`, an `error` corpus finding makes `sdp validate` exit nonzero and a `warning` stays informative; without `--rules` no corpus rule runs and only the Protocol's validators set the exit code.", + "A corpus finding moves no readiness floor and reaches no other surface: not the report `sdp q` hands a body, not the reader, not the written graph, and not the Design Review or another projection.", + "A rules file that cannot be read or does not parse, a body that throws, or an answer that is not an object with a `findings` array of well-formed findings is a rule execution failure; `sdp validate` reports it in the corpus section and exits nonzero, so a broken rule never passes as a clean run. A failed file reports none of its findings, and the other files still run.", + "`sdp validate --watch` with `--rules` reads and runs every named rules file afresh in each cycle, after the Protocol's validators; an edit to a rules file alone starts no cycle, because the watcher watches carriers.", + "When the graph does not derive, no rules file runs, and the corpus summary line says so.", + ], + exampleSpace: { + given: [ + "a probe corpus the Protocol's validators find clean", + 'a first rules file that {first:"returns one error finding"|"returns one warning finding"|"returns a finding with no subject"|"empties the graph it receives"}', + "a second rules file that returns one warning when the graph it receives holds no node", + ], + when: [ + "the probe corpus is validated with the rules files named by --rules in that order", + ], + then: [ + "the command exits {exitCode:number}", + "the Protocol summary line reads {protocolSummary:string}", + "the corpus summary line reads {corpusSummary:string}", + ], + }, + ruleKeys: { + readsGraphOnly: "rules[1]", + operatorNamed: "rules[2]", + namedOrder: "rules[3]", + copiesPerFile: "rules[4]", + exitCode: "rules[9]", + neverMovesFloor: "rules[10]", + brokenRuleFails: "rules[11]", + watchReruns: "rules[12]", + }, + }, + design: { + rulesFlag: + "`sdp validate [root] [--exclude PATH]... [--check-clean | --watch] [--rules PATH [--params JSON | --params @PATH | --params KEY=@PATH]...]...`; `--rules` and `--params` are options of `validate` alone, a `--params` before the first `--rules` is refused, one file named twice is refused, including through a symlink, and each PATH resolves from the working directory, as a `--params @PATH` file does. Diagnostics retain the path the operator named.", + paramsFlag: + "`--params` on `validate` takes the three forms of `sdp q --params` with the same refusals, before the graph derives; each `--params` belongs to the nearest `--rules` before it in the argument list, whatever other options stand between them; one file's parameters merge as `sdp q` merges them, a key given twice for one file is refused, two files may each take the same key, and a file with no `--params` after it receives `{}`.", + rulesFile: + "a rules file is UTF-8 text holding one plain JavaScript async function body, the form `sdp q` evaluates, with the bindings `g`, `graph`, `report` and `params`; the runner reads the text and never imports it as a module, so no file extension is required and a catalog recipe file under `dist/recipes/` is a lawful rules file.", + ruleAnswer: + "`{ findings: [] }`: the body returns an object whose `findings` is an array, possibly empty; the runner reads `findings` and nothing else, so the answer's other keys stay the body's own data and are never printed.", + ruleFinding: + '`{ validatorId, subjectId, severity, message, relatedId?, path?, file?, line? }`, where `validatorId` is the rule id, one token matching `^[a-z][a-z0-9-]*$`, so it never reads as a Protocol validator id; `subjectId` and `message` are non-empty strings; `severity` is `"error"` or `"warning"`; `relatedId` and `path` are strings; `file` is a non-empty root-relative POSIX path, with absolute paths, backslashes and traversal above the root refused, and `line` a positive integer that requires `file`; the runner ignores a finding\'s other keys.', + corpusFinding: + "`{ rulesFile, validatorId, subjectId, severity, message, relatedId?, path?, file?, line? }` is the corpus branch of the finding currency, where `rulesFile` is the path as the operator named it; it carries no `family`, and the Protocol's findings keep their shape.", + corpusReport: + "the report a run with `--rules` builds keeps the Protocol's findings unchanged in `findings` and adds `corpusFindings`, the corpus findings of each file that did not fail, in the order the files are named and then in the order returned, and `corpusFailures`, one `{ rulesFile, reason }` for each failed file in the order named, never a finding; the report a rules file receives, like the one `sdp q` hands a body, carries neither.", + corpusSection: + "on standard error, after every Protocol finding, each rules file in the order named prints the line `corpus rules <path>` and then its findings in the order returned, each in the one composition of location, severity in brackets, rule id and message; a failed file prints `corpus rules <path> failed: <reason>` in place of its findings.", + corpusSummary: + "`corpus rules: <errors> errors · <warnings> warnings · <failed> of <files> files failed (the corpus's own rules)`, written to standard output directly after the Protocol's summary line, which stays as `spec:validation.diagnostic-rendering#design.summaryLine` pins it; without `--rules` no such line prints; when the graph does not derive, no rules file runs and no Protocol summary line prints, and the corpus line, the last line on standard output, reads `corpus rules: not run, the graph did not derive`.", + exitCodes: + "with `--rules`, `sdp validate` exits 1 when a Protocol finding is an error, a corpus finding is an error, a rules file fails or the graph does not derive, and 0 otherwise; the two summary lines say which.", + failureReasons: + "`<reason>` is `cannot be read (<cause>)`, `does not parse at line <L>, column <C>: <cause>`, `threw: <message on one line>`, `returned no findings array`, followed by `; diagnostics: <the answer's diagnostics as JSON>` when the answer carries `diagnostics` or by `; unknownParams: <as JSON>; accepts: <as JSON>` when it carries `unknownParams`, or `finding <N> has no lawful <field>`, with N counted from 1.", + }, + }, + deliveryFacts: ["implemented", "has-verifier"], + }, + { + id: "spec:validation.corpus-rules.copies-contain-changes", + specKind: "example", + altitude: "story", + readiness: "ready", + file: "specs/validation/corpus-rules.copies-contain-changes.sdp.md", + title: "A rules file that empties its graph leaves the next file's graph whole", + narrative: null, + sections: { + intent: { + outcome: + "Execute the copy rule on two rules files, the first changing the graph it receives and the second reading its own.", + assumptions: [ + "The world writes the probe corpus of the error example. The first rules file's body sets the length of `graph.nodes` and of `graph.edges` to zero and returns `{ findings: [] }`. The second rules file's body returns one warning finding when `graph.nodes` is empty and `{ findings: [] }` otherwise. The world runs `sdp validate` on the probe root with the first file, then the second, named by `--rules`, and reads the exit code and standard output.", + ], + }, + behavior: { + examples: [ { - question: - "Does an operator-named rules file hold one async body returning findings or a module exporting named rule functions, how are parameters supplied, in what order do several files run, and does `sdp validate --watch` rerun them?", - blocking: false, - key: "rulesRepresentation", - decider: "owner", - provisional: - "one async body per file, given `g`, `graph`, `report` and `params`, with `params` from `sdp validate --params` and `{}` without it; files run in the order named, each to completion; a rule id is unique within its file; `sdp validate --watch` runs the same named rules each cycle", + given: [ + "a probe corpus the Protocol's validators find clean", + 'a first rules file that {first: "empties the graph it receives"}', + "a second rules file that returns one warning when the graph it receives holds no node", + ], + when: [ + "the probe corpus is validated with the rules files named by --rules in that order", + ], + then: [ + "the command exits {exitCode: 0}", + 'the Protocol summary line reads {protocolSummary: "validate: 0 errors · 0 warnings (conformance + honesty over the one graph)"}', + 'the corpus summary line reads {corpusSummary: "corpus rules: 0 errors · 0 warnings · 0 of 2 files failed (the corpus\'s own rules)"}', + ], }, + ], + }, + }, + deliveryFacts: ["has-verifier"], + }, + { + id: "spec:validation.corpus-rules.error-finding-fails", + specKind: "example", + altitude: "story", + readiness: "ready", + file: "specs/validation/corpus-rules.error-finding-fails.sdp.md", + title: "An error from a corpus rule fails validation and leaves the Protocol's line alone", + narrative: null, + sections: { + intent: { + outcome: + "Execute the exit rule on one rules file that returns one error finding over a corpus the Protocol finds clean.", + assumptions: [ + 'The world writes a probe corpus of two story-altitude behavior Specs stating `idea`, each with an Intent outcome, the second declaring `refines` on the first, so the Protocol\'s validators report no finding. The rules file\'s body returns `{ findings: [{ validatorId: "probe-rule", subjectId: "probe-subject", severity: "error", message: "the probe rule reports its subject" }] }`. The world runs `sdp validate` on the probe root with that file named by `--rules` and reads the exit code and standard output.', + ], + }, + behavior: { + examples: [ { - question: - "What shape does a corpus finding take beside the one diagnostic currency, where does the report carry it, and does it reach `report`, the reader's `findings()` or the Design Review?", - blocking: false, - key: "findingCurrency", - decider: "owner", - provisional: - "the shared finding contract has a Protocol branch carrying `validatorId`, `family`, severity and message, and a corpus branch carrying the corpus, the rule id as `validatorId`, `subjectId`, severity and message and no family; `relatedId`, `path`, `file` and `line` stay optional on both branches; corpus findings are collected as `corpusFindings` in the same report and rendered under a corpus-labelled section with the existing location, severity, identifier and message composition; Protocol findings, the summary line and the Design Review stay unchanged, as the frozen-projections decision requires", + given: [ + "a probe corpus the Protocol's validators find clean", + 'a first rules file that {first: "returns one error finding"}', + ], + when: [ + "the probe corpus is validated with the rules files named by --rules in that order", + ], + then: [ + "the command exits {exitCode: 1}", + 'the Protocol summary line reads {protocolSummary: "validate: 0 errors · 0 warnings (conformance + honesty over the one graph)"}', + 'the corpus summary line reads {corpusSummary: "corpus rules: 1 errors · 0 warnings · 0 of 1 files failed (the corpus\'s own rules)"}', + ], + }, + ], + }, + }, + deliveryFacts: ["has-verifier"], + }, + { + id: "spec:validation.corpus-rules.malformed-finding-fails", + specKind: "example", + altitude: "story", + readiness: "ready", + file: "specs/validation/corpus-rules.malformed-finding-fails.sdp.md", + title: "A finding with no subject fails its rules file", + narrative: null, + sections: { + intent: { + outcome: + "Execute the broken-rule rule on one rules file whose only finding carries no subject.", + assumptions: [ + 'The world writes the probe corpus of the error example. The rules file\'s body returns `{ findings: [{ validatorId: "probe-rule", severity: "warning", message: "the probe rule forgets its subject" }] }`, a warning that would pass if it were well formed. The world runs `sdp validate` on the probe root with that file named by `--rules`, reads the exit code and standard output, and reads on standard error the failure line naming the first finding\'s `subjectId`.', + ], + }, + behavior: { + examples: [ + { + given: [ + "a probe corpus the Protocol's validators find clean", + 'a first rules file that {first: "returns a finding with no subject"}', + ], + when: [ + "the probe corpus is validated with the rules files named by --rules in that order", + ], + then: [ + "the command exits {exitCode: 1}", + 'the Protocol summary line reads {protocolSummary: "validate: 0 errors · 0 warnings (conformance + honesty over the one graph)"}', + 'the corpus summary line reads {corpusSummary: "corpus rules: 0 errors · 0 warnings · 1 of 1 files failed (the corpus\'s own rules)"}', + ], }, + ], + }, + }, + deliveryFacts: ["has-verifier"], + }, + { + id: "spec:validation.corpus-rules.warning-finding-informs", + specKind: "example", + altitude: "story", + readiness: "ready", + file: "specs/validation/corpus-rules.warning-finding-informs.sdp.md", + title: "A warning from a corpus rule informs and passes", + narrative: null, + sections: { + intent: { + outcome: + "Execute the exit rule on one rules file that returns one warning finding over a corpus the Protocol finds clean.", + assumptions: [ + 'The world writes the probe corpus of the error example. The rules file\'s body returns `{ findings: [{ validatorId: "probe-rule", subjectId: "probe-subject", severity: "warning", message: "the probe rule reports its subject" }] }`. The world runs `sdp validate` on the probe root with that file named by `--rules` and reads the exit code and standard output.', + ], + }, + behavior: { + examples: [ { - question: - "Does the runner enforce that a rule reads only the graph and changes nothing, or are both obligations on the rule's author?", - blocking: false, - key: "ruleIsolation", - decider: "owner", - provisional: - "changing nothing is a guarantee, with the Protocol's validators run first and each rule given a frozen or cloned graph; reading only the graph is the author's obligation, run with the trust `sdp q` asks and no sandbox", + given: [ + "a probe corpus the Protocol's validators find clean", + 'a first rules file that {first: "returns one warning finding"}', + ], + when: [ + "the probe corpus is validated with the rules files named by --rules in that order", + ], + then: [ + "the command exits {exitCode: 0}", + 'the Protocol summary line reads {protocolSummary: "validate: 0 errors · 0 warnings (conformance + honesty over the one graph)"}', + 'the corpus summary line reads {corpusSummary: "corpus rules: 0 errors · 1 warnings · 0 of 1 files failed (the corpus\'s own rules)"}', + ], }, ], }, + }, + deliveryFacts: ["has-verifier"], + }, + { + id: "spec:validation.architecture-constraints.forbidden-layer-pair", + specKind: "example", + altitude: "story", + readiness: "ready", + file: "specs/validation/architecture-constraints.forbidden-layer-pair.sdp.md", + title: "A domain member that uses an infrastructure member breaks a forbidden pair", + narrative: null, + sections: { + intent: { + outcome: + "Execute the walk through `sdp q` on one `uses` edge between members of two components whose layers form a forbidden pair.", + assumptions: [ + 'The world writes a probe root with no Spec and two comment-form `component:` anchors, each with one `impl:` member; the using member\'s anchor `uses` the used member. Each component states the layer and context its slots give, and the Protocol\'s validators find the root clean. The world runs the walk\'s shipped recipe file through `sdp q --json` with `params` `{ "forbidden": [{ "from": "domain", "to": "infrastructure" }], "isolated": ["platform"] }`; the rule ids are the findings\' `validatorId` values joined by a comma and a space, in answer order.', + ], + }, behavior: { - rules: [ - "A corpus rule is a graph body the corpus authors, given `g`, `graph`, `report` and `params` as a recipe body is, and returning findings that each carry a rule id, a subject, a message and a severity.", - "A corpus rule reads the one derived graph and nothing else, so no rule reads a carrier, a source file or the network.", - "Each corpus finding names its rule and its corpus as its source and stands apart from the Protocol's own findings; the Protocol states none of these rules and ships none.", - "Corpus findings stand outside the conformance and honesty families, in a section of the report labelled as the corpus's own, so the two families keep their meaning and the guardrail that checks never judge content quality or workflow keeps binding the Protocol's own validators.", - "Validation runs corpus rules only from a file the operator names with `sdp validate --rules PATH`, which may repeat, and never from a file it finds in the corpus or in a configuration; it asks the trust `sdp q` already asks, to run code the operator chose.", - "A corpus finding carries severity `error` or `warning`. When the operator names `--rules`, an `error` corpus finding makes `sdp validate` exit nonzero and a `warning` stays informative; without `--rules` no corpus rule runs and only the Protocol's validators set the exit code.", - "Corpus findings keep their own source and report section, are tallied apart from the Protocol's summary line, and neither add to, suppress nor change a Protocol finding or the exit reason it sets.", - "A rules file that cannot load, a rule that throws, or a rule that returns a finding without a rule id, subject, message and admitted severity is a rule execution failure; `sdp validate` reports it in the corpus section and exits nonzero, so a broken rule never passes as a clean run.", - "A corpus rule changes no node, edge or delivery fact; it reports on the graph it is given.", + examples: [ + { + given: [ + 'a member of a component at the layer {fromLayer: "domain"} in the context {fromContext: "orders"} that uses a member of a component at the layer {toLayer: "infrastructure"} in the context {toContext: "orders"}', + ], + when: [ + 'the architecture walk runs {invocation: "through sdp q"} with the forbidden pair domain to infrastructure and the isolated context platform', + ], + then: [ + 'the walk reports {findings: 1} findings with the rule ids {ruleIds: "forbidden-layer"}', + "the walk lists {unclassified: 0} unclassified edges", + "the command exits {exitCode: 0}", + ], + }, ], - ruleKeys: { - operatorNamed: "rules[4]", - exitCode: "rules[5]", - brokenRuleFails: "rules[7]", - }, }, }, - deliveryFacts: [], + deliveryFacts: ["has-verifier"], + }, + { + id: "spec:validation.architecture-constraints.isolated-context-as-rule", + specKind: "example", + altitude: "story", + readiness: "ready", + file: "specs/validation/architecture-constraints.isolated-context-as-rule.sdp.md", + title: "The walk named as a rules file fails validation on an isolation breach", + narrative: null, + sections: { + intent: { + outcome: + "Execute the walk's shipped recipe file, unchanged, as a rules file under `sdp validate --rules` on one `uses` edge that leaves an isolated context.", + assumptions: [ + "The world writes the probe root of the forbidden-pair example, with the layers and contexts the slots give. It runs `sdp validate` on that root with the walk's shipped recipe file named by `--rules` and followed by `--params` with the parameters of that example. The findings are the lines in that file's corpus section, and the rule ids are their rule ids joined by a comma and a space, in output order.", + ], + }, + behavior: { + examples: [ + { + given: [ + 'a member of a component at the layer {fromLayer: "application"} in the context {fromContext: "platform"} that uses a member of a component at the layer {toLayer: "adapter"} in the context {toContext: "orders"}', + ], + when: [ + 'the architecture walk runs {invocation: "as a rules file named by sdp validate --rules"} with the forbidden pair domain to infrastructure and the isolated context platform', + ], + then: [ + 'the walk reports {findings: 1} findings with the rule ids {ruleIds: "isolated-context"}', + "the command exits {exitCode: 1}", + ], + }, + ], + }, + }, + deliveryFacts: ["has-verifier"], + }, + { + id: "spec:validation.architecture-constraints.unstated-layer-unclassified", + specKind: "example", + altitude: "story", + readiness: "ready", + file: "specs/validation/architecture-constraints.unstated-layer-unclassified.sdp.md", + title: "A component with no layer leaves its edge unclassified", + narrative: null, + sections: { + intent: { + outcome: + "Execute the walk through `sdp q` on one `uses` edge whose used component states no layer.", + assumptions: [ + "The world writes the probe root of the forbidden-pair example, except that the used component's anchor states no layer, which the `unstated` value names. It runs the walk as that example does; an empty rule-id string names no finding.", + ], + }, + behavior: { + examples: [ + { + given: [ + 'a member of a component at the layer {fromLayer: "domain"} in the context {fromContext: "platform"} that uses a member of a component at the layer {toLayer: "unstated"} in the context {toContext: "platform"}', + ], + when: [ + 'the architecture walk runs {invocation: "through sdp q"} with the forbidden pair domain to infrastructure and the isolated context platform', + ], + then: [ + 'the walk reports {findings: 0} findings with the rule ids {ruleIds: ""}', + "the walk lists {unclassified: 1} unclassified edges", + "the command exits {exitCode: 0}", + ], + }, + ], + }, + }, + deliveryFacts: ["has-verifier"], }, ] as const; diff --git a/test/skills.test.ts b/test/skills.test.ts index 2098cb5b..c902523b 100644 --- a/test/skills.test.ts +++ b/test/skills.test.ts @@ -304,6 +304,7 @@ describe("Protocol skill assets", () => { "mermaid", "new spec", "q", + "studio", "validate", "view", ].sort(), @@ -600,8 +601,14 @@ const adopterClauses = [ ["mention register source", /prose mentions from recipe 22/u], ["delta scope source", /a change's review scope from changed-file blast radius \(recipe 4\)/u], ["no quoted counts", /In prose, name the recipe and leave the number out/u], - ["checks only ruled homes", /sdp validate checks these homes and nothing your project adds/u], - ["adopter owns policy", /Keep a project policy,[^.]*as a script in your own repository/u], + ["operator-selected policy", /operator-selected rules file through sdp validate --rules PATH/u], + ["per-file policy parameters", /Each --params follows its own --rules/u], + [ + "separate policy findings", + /Corpus findings name their rule and rules file and print apart from Protocol/u, + ], + ["policy failures", /any Protocol error, corpus error or failed rules file exits 1/u], + ["watch policy rereads", /each carrier cycle rereads the rules files/u], [ "Constraints leading-prose exception", /A section may open with paragraphs, except Constraints, which takes its entries only/u, @@ -765,6 +772,24 @@ describe("adopter rule meanings", () => { }); }); +const readmeClauses = [ + ["commit pin", /github:libar-dev\/software-delivery-protocol#<sha>/u], + ["npm script approval", /allowScripts/u], + ["npm git approval", /allow-git=root/u], + ["pnpm repository approval", /allowBuilds/u], + ["pnpm 10 exact approval", /onlyBuiltDependencies \(pnpm 10\)/u], + ["installed CLI check", /node_modules\/\.bin\/sdp --version/u], + ["recipe subject as params", /dist\/recipes\/25-address-resolution\.js\)" --params/u], +] as const; + +describe("adopter install and upgrade guidance", () => { + it.each(readmeClauses)("the README teaches %s", (_clause, pattern) => { + const readme = readFileSync(join(repoRoot, "README.md"), "utf8"); + // Keep underscores in executable paths while ignoring code spans and line wrapping. + expect(readme.replaceAll("`", "").replaceAll(/\s+/gu, " ")).toMatch(pattern); + }); +}); + function grammarProbe(section: string) { return parseMarkdownBody( `# Teaching probe\n\n${section}\n`, diff --git a/test/studio-data.test.ts b/test/studio-data.test.ts new file mode 100644 index 00000000..a97d1a8b --- /dev/null +++ b/test/studio-data.test.ts @@ -0,0 +1,268 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; +import { ref, specTest, testAnchorId } from "@libar-dev/software-delivery-protocol"; + +import { extract } from "../src/extract/index.js"; +import { schemaVersion } from "../src/graph/schema.js"; +import type { GraphSchema } from "../src/graph/schema.js"; +import { + createStudioData, + embedData, + packTotals, + railValues, + searchIndex, +} from "../src/projections/studio/data.js"; +import { pagePath } from "../src/projections/studio/paths.js"; +import { createReader } from "../src/reader/reader.js"; +import { entryMatches } from "../src/studio-elements/search-results.js"; +import { runSdpCli } from "../src/cli/sdp.js"; +import { createCaptureOutput } from "./helpers/cli-capture.js"; + +const anchor = specTest({ + id: testAnchorId("test:protocol.studio-data"), + verifies: [ref("spec:consumers.spec-studio.data"), ref("spec:consumers.spec-studio.publisher")], +}); +void anchor; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const corpora = [ + { name: "Protocol", root, exclude: ["explorations", "examples", "test/fixtures/import/parity"] }, + { name: "checkout", root: join(root, "examples/checkout-v1"), exclude: [] }, +].map((corpus) => ({ ...corpus, extracted: extract(corpus) })); + +function required<T>(value: T | null | undefined): T { + if (value === null || value === undefined) throw new Error("Expected a value"); + return value; +} + +function recipeBody(ordinal: number): string { + const catalog = readFileSync(join(root, "docs/agent-surface/recipes.md"), "utf8"); + const section = catalog.split(new RegExp(`^## ${String(ordinal)}\\. `, "mu"))[1]; + const body = section?.split("```js\n")[1]?.split("\n```")[0]; + if (body === undefined) throw new Error(`recipe ${String(ordinal)} missing`); + return body; +} + +async function recipe<T>( + ordinal: number, + corpus: (typeof corpora)[number], + params: Record<string, unknown>, +): Promise<T> { + const capture = createCaptureOutput(); + expect( + await runSdpCli( + [ + "q", + recipeBody(ordinal), + "--root", + corpus.root, + "--params", + JSON.stringify(params), + "--json", + ], + capture.output, + { query: { extract: () => corpus.extracted } }, + ), + ).toBe(0); + return JSON.parse(capture.readStdout()) as T; +} + +describe.each(corpora)("Studio data over $name", (corpus) => { + const reader = createReader(corpus.extracted.graph); + + it("counts every Pack exactly as recipe 29", async () => { + expect(reader.packs().length).toBeGreaterThan(0); + for (const pack of reader.packs()) { + const answer = await recipe<{ totals: unknown }>(29, corpus, { pack: pack.id }); + expect(packTotals(required(reader.packContext(pack.id)))).toEqual(answer.totals); + } + }); + + it("keeps one search record per node, fields unchanged and bindings decoded", () => { + const records = searchIndex(reader).records; + expect(records.map((record) => record.id)).toEqual( + corpus.extracted.graph.nodes.map((node) => node.id).sort(), + ); + for (const node of corpus.extracted.graph.nodes) { + const record = required(records.find((candidate) => candidate.id === node.id)); + for (const field of [ + "title", + "label", + "framing", + "narrative", + "sections", + "file", + "line", + ] as const) { + expect(record[field]).toEqual(field in node ? node[field as keyof typeof node] : undefined); + } + const spec = reader.specContext(node.id); + expect(record.page).toBe( + node.nodeType === "Primitive" || node.nodeType === "Pack" ? pagePath(node.id) : null, + ); + expect(record.bindings).toEqual( + node.nodeType === "Anchor" || node.nodeType === "CodeNode" + ? corpus.extracted.graph.edges + .filter((edge) => edge.from === node.id && reader.specContext(edge.to) !== undefined) + .map((edge) => ({ edge: edge.type, specId: edge.to })) + : [], + ); + expect(record.floorReached).toBe( + spec === undefined ? undefined : (spec.derivedReadiness ?? null), + ); + expect(record.statedReadiness).toBe(spec?.statedReadiness); + } + }); + + it.each(["suffix", "rule", "type", "outcome", "order", "decision", "graphOnly"])( + "matches recipe 23's exact rows for %s", + async (term) => { + const answer = await recipe<{ + matches: { matchedIn: string[] }[]; + totals: { matches: number }; + }>(23, corpus, { + term, + }); + const rows = searchIndex(reader).records.flatMap((record) => + record.entries.flatMap((entry) => { + const row = { + section: entry.section, + entry: entry.entry, + address: entry.address, + line: entry.line, + text: entry.text, + }; + return entryMatches(record, entry, term) ? [{ id: record.id, ...row }] : []; + }), + ); + expect(rows.slice(0, 50)).toEqual( + answer.matches.map(({ matchedIn, ...row }) => { + void matchedIn; + return row; + }), + ); + expect(rows.length).toBe(answer.totals.matches); + }, + ); + + it("keeps rails in Pack-id and authored-member order, with severity and space ownership", () => { + const rails = railValues(reader); + expect(rails.packs.map((pack) => pack.id)).toEqual( + reader + .packs() + .map((pack) => pack.id) + .sort(), + ); + for (const pack of rails.packs) { + expect(pack.members.map((member) => member.id)).toEqual( + reader.packContext(pack.id)?.members.map((member) => member.id), + ); + for (const member of pack.members) + expect(member.ownsExampleSpace).toBe( + reader.specContext(member.id)?.sections?.behavior?.exampleSpace !== undefined, + ); + } + expect(rails.findings).toEqual({ + errors: reader.findings().filter((finding) => finding.severity === "error").length, + warnings: reader.findings().filter((finding) => finding.severity === "warning").length, + }); + const data = createStudioData(reader); + expect(data.specs).toEqual(reader.specs().map((spec) => reader.specContext(spec.id))); + expect(data.findings).toEqual(reader.findings()); + expect(data.packs.map((pack) => pack.context)).toEqual( + reader.packs().map((pack) => reader.packContext(pack.id)), + ); + expect(data.rail).toEqual(rails); + }); +}); + +it("serializes embedded JSON with no literal less-than character", () => { + const value = { text: "</script><script>bad()</script>", unicode: "<ä" }; + const encoded = embedData(value); + expect(encoded).not.toContain("<"); + expect(encoded).toContain("\\u003c/script>"); + expect(JSON.parse(encoded)).toEqual(value); +}); + +it("encodes uppercase ids without case-insensitive page collisions", () => { + expect(pagePath("spec:demo.CreateOrder")).toBe("specs/demo._create_order.html"); + expect(pagePath("pack:Demo")).toBe("packs/_demo.html"); + expect(pagePath("impl:demo")).toBeNull(); + expect(pagePath("spec:demo.CreateOrder")?.toLowerCase()).not.toBe( + pagePath("spec:demo.createorder")?.toLowerCase(), + ); +}); + +it("preserves coined non-string entries, question and rule addresses and their lines", () => { + const graph: GraphSchema = { + schemaVersion, + nodes: [ + { + id: "spec:probe.entries", + nodeType: "Primitive", + claim: "declared", + specKind: "behavior", + altitude: "story", + readiness: "idea", + file: "probe.sdp.md", + narrative: "Text", + sections: { + intent: { + outcome: "Outcome", + openQuestions: [{ question: "Question", key: "whyHere", blocking: false }], + }, + behavior: { rules: ["Rule"], ruleKeys: { oneRule: "rules[0]" } }, + design: { coinedShape: { nested: "Nested" } }, + }, + }, + ], + edges: [], + locations: [ + { spec: "spec:probe.entries", entry: "design.coinedShape", file: "probe.sdp.md", line: 20 }, + { spec: "spec:probe.entries", entry: "question[0]", file: "probe.sdp.md", line: 12 }, + { spec: "spec:probe.entries", entry: "rule[0]", file: "probe.sdp.md", line: 16 }, + ], + }; + const entries = required(searchIndex(createReader(graph)).records[0]).entries; + expect(entries).toContainEqual({ + section: "design", + entry: "coinedShape", + key: "coinedShape", + address: "spec:probe.entries#design.coinedShape", + line: 20, + text: '{"nested":"Nested"}', + }); + expect(entries).toContainEqual({ + section: "design", + entry: "coinedShape.nested", + key: "nested", + address: null, + line: null, + text: "Nested", + }); + expect(entries).toContainEqual({ + section: "intent", + entry: "openQuestions[0].question", + key: "whyHere", + address: "spec:probe.entries#question.whyHere", + line: 12, + text: "Question", + }); + expect(entries).toContainEqual({ + section: "behavior", + entry: "rules[0]", + key: "oneRule", + address: "spec:probe.entries#rule.oneRule", + line: 16, + text: "Rule", + }); + expect( + entries.some( + (entry) => + entry.entry?.includes("ruleKeys") === true || entry.entry?.endsWith(".key") === true, + ), + ).toBe(false); +}); diff --git a/test/studio-elements.test.ts b/test/studio-elements.test.ts new file mode 100644 index 00000000..8bdf1c8f --- /dev/null +++ b/test/studio-elements.test.ts @@ -0,0 +1,510 @@ +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { describe, expect, it } from "vitest"; + +import { ref, specTest, testAnchorId } from "../src/anchors.js"; +import { rowMatches } from "../src/studio-elements/filter.js"; +import { searchFragment, searchTerm, searchUrl } from "../src/studio-elements/search.js"; +import { + entryMatches, + entryUrl, + resultPage, + renderResults, + searchRecords, + type SearchRecord, + type SearchEntry, +} from "../src/studio-elements/search-results.js"; +import { stampUrl, stampText } from "../src/studio-elements/stamp.js"; +import { copyText } from "../src/studio-elements/copy.js"; +import { fragmentTarget } from "../src/studio-elements/expander.js"; + +const anchor = specTest({ + id: testAnchorId("test:protocol.studio-page-elements"), + verifies: [ + ref("spec:consumers.spec-studio.page-elements"), + ref("spec:consumers.spec-studio.search"), + ], +}); +void anchor; + +const record: SearchRecord = { + id: "spec:probe.find", + nodeType: "Primitive", + page: "specs/probe.find.html", + title: "Find <script>", + specKind: "behavior", + statedReadiness: "defined", + floorReached: "ready", + bindings: [], + sections: { design: { verifyPass: { needle: true }, pin: "`Needle`", str: '{"needle":true}' } }, + entries: [ + { + section: "design", + entry: "verifyPass", + key: "verifyPass", + address: "spec:probe.find#design.verifyPass", + line: 9, + text: '{"needle":true}', + }, + { + section: "design", + entry: "pin", + key: "pin", + address: "spec:probe.find#design.pin", + line: 10, + text: "`Needle`", + }, + { + section: "design", + entry: "str", + key: "str", + address: "spec:probe.find#design.str", + line: 11, + text: '{"needle":true}', + }, + ], +}; + +const entryAt = (index: number): SearchEntry => { + const entry = record.entries[index]; + if (entry === undefined) throw new Error("Missing test entry"); + return entry; +}; + +describe("Studio page interactions", () => { + it("offers a prompt before searching and puts a real miss before the field list", () => { + for (const term of ["", " "]) { + const html = renderResults([], term, 50); + expect(html).toContain("Type a word to search."); + expect(html).toContain("Fields searched:"); + expect(html).not.toMatch(/No node matches|0 nodes/u); + } + const miss = renderResults([], "missing <&>", 50); + expect(miss).toContain("No node matches missing <&>"); + expect(miss.indexOf("No node matches")).toBeLessThan(miss.indexOf("Fields searched:")); + }); + it("writes singular counts, readable fields, code-span titles and linked entry keys", () => { + const single: SearchRecord = { + ...record, + title: "Use `safe <&>`", + sections: { design: { aggregateReadModelRebuild: "needle" }, intent: { outcome: "needle" } }, + entries: [ + { + ...entryAt(1), + entry: "aggregateReadModelRebuild", + key: "aggregateReadModelRebuild", + text: "needle", + }, + ], + }; + const html = renderResults(searchRecords({ records: [single] }, "needle"), "needle", 50); + expect(html).toContain("1 node and 1 entry match"); + expect(html).toContain("Use <code>safe <&></code>"); + expect(html).toContain("Spec · behavior · stated defined · floor ready"); + expect(html).toContain("Matched in: the Design section, the Intent section"); + expect(html).not.toContain("sections.design"); + expect(html).toContain( + '<a href="specs/probe.find.html#design.pin"><code>aggregateReadModelRebuild</code></a> · Design · line 10', + ); + }); + it("cuts long excerpts at word boundaries around the term", () => { + const long: SearchRecord = { + ...record, + narrative: "projection ".repeat(40) + "needle " + "continuation ".repeat(30), + entries: [], + }; + const entry = { + ...entryAt(1), + section: "narrative", + address: null, + text: long.narrative ?? "", + }; + const html = renderResults( + [{ record: long, matchedIn: ["narrative"], entries: [entry] }], + "needle", + 50, + ); + expect(html).toContain("needle"); + expect(html).not.toMatch(/…(?:rojection|ontinuation)|(?:projectio|continuatio)…/u); + expect(html).toContain("…projection"); + }); + it("copies the requested text and reports successful clipboard writes", async () => { + const written: string[] = []; + const selected: string[] = []; + expect( + await copyText( + "spec:probe.find#design.pin", + (text) => { + written.push(text); + return Promise.resolve(); + }, + (text) => { + selected.push(text); + }, + ), + ).toBe("Copied"); + expect(written).toEqual(["spec:probe.find#design.pin"]); + expect(selected).toEqual([]); + }); + + it("selects copy text when the clipboard is missing or rejects access", async () => { + const selected: string[] = []; + for (const write of [undefined, () => Promise.reject(new Error("Clipboard denied"))]) { + expect( + await copyText("copy me", write, (text) => { + selected.push(text); + }), + ).toBe("Text selected. Copy it with your keyboard."); + } + expect(selected).toEqual(["copy me", "copy me"]); + }); + + it("decodes entry fragments literally and refuses malformed fragment escapes", () => { + expect(fragmentTarget("#design.pin")).toBe("design.pin"); + expect(fragmentTarget("#question.%63overage")).toBe("question.coverage"); + expect(fragmentTarget("#%E0%A4")).toBeNull(); + expect(fragmentTarget("#")).toBeNull(); + }); + + it("filters all facets together and accepts space-separated row values", () => { + expect( + rowMatches( + { kind: "behavior", packs: "pack:a pack:b" }, + { kind: "behavior", packs: "pack:b" }, + ), + ).toBe(true); + expect(rowMatches({ kind: "behavior", packs: "pack:ab" }, { packs: "pack:a" })).toBe(false); + expect(rowMatches({ kind: "behavior" }, { kind: "any", packs: "any" })).toBe(true); + expect(rowMatches({ kind: "behavior" }, { kind: "rule" })).toBe(false); + expect(rowMatches({}, { packs: "pack:a" })).toBe(false); + }); + + it("round-trips term fragments including punctuation, spaces and Unicode", () => { + for (const term of ["retry worker", "a+b & #?/%", "čitanje", ""]) { + expect(searchTerm(searchFragment(term))).toBe(term); + expect(searchUrl("../", term)).toBe(`../search.html${searchFragment(term)}`); + } + expect(searchTerm("#other")).toBe(""); + expect(searchTerm("#q=%E0%A4")).toBe(""); + expect(searchTerm("#q=a+b")).toBe("a+b"); + }); + + it("matches entry tokens, and non-string values only by their coined key", () => { + expect(record.entries.map((entry) => entryMatches(record, entry, "needle"))).toEqual([ + false, + true, + true, + ]); + expect(entryMatches(record, entryAt(0), "verify pass")).toBe(true); + expect(entryMatches(record, entryAt(0), "verify")).toBe(true); + expect(entryMatches(record, entryAt(1), "need")).toBe(false); + expect(entryMatches(record, entryAt(1), "")).toBe(false); + }); + + it("keeps a node whose only match is a tokenized coined entry key", () => { + const keyed: SearchRecord = { + ...record, + sections: { design: { verifyPass: 42 } }, + entries: [{ ...entryAt(0), text: "42" }], + }; + expect(searchRecords({ records: [keyed] }, "verify pass")).toEqual([ + { record: keyed, matchedIn: ["key"], entries: keyed.entries }, + ]); + }); + + it("reads authored values at most twice per search, regardless of the entry count", () => { + const count = 128; + let reads = 0; + const design: Record<string, unknown> = {}; + const entries = Array.from({ length: count }, (_, index) => { + const key = `entry${String(index)}`; + Object.defineProperty(design, key, { + enumerable: true, + get: () => { + reads += 1; + return "needle"; + }, + }); + return { ...entryAt(1), entry: key, key, text: "needle" }; + }); + const large: SearchRecord = { ...record, sections: { design }, entries }; + for (const term of ["needle", "absent"]) { + reads = 0; + const hits = searchRecords({ records: [large] }, term); + expect(hits.flatMap((hit) => hit.entries)).toHaveLength(term === "needle" ? count : 0); + expect(reads).toBeLessThanOrEqual(2 * count); + } + }); + + it("links addressed hits to the entry and other hits to their section or narrative", () => { + expect(entryUrl(record, entryAt(0))).toBe("specs/probe.find.html#design.verifyPass"); + expect(entryUrl(record, { ...entryAt(0), address: null })).toBe( + "specs/probe.find.html#section.design", + ); + expect(entryUrl(record, { ...entryAt(0), section: "narrative", address: null })).toBe( + "specs/probe.find.html#narrative", + ); + }); + + it("groups entries beneath each node and pages fifty nodes at a time", () => { + const hits = searchRecords( + { + records: Array.from({ length: 103 }, (_, i) => ({ + ...record, + id: `spec:probe.${String(i).padStart(3, "0")}`, + })), + }, + "needle", + ); + const page = resultPage(hits, 50); + expect(page.shown).toHaveLength(50); + expect(page.total).toBe(103); + expect(page.entryTotal).toBe(206); + expect(page.remaining).toBe(53); + expect(resultPage(hits, 100).remaining).toBe(3); + expect(resultPage(hits, 150).shown).toHaveLength(103); + expect(hits[0]?.entries).toEqual(record.entries.slice(1)); + }); + + it("renders node words, independent rungs, bindings, locations and escaped text", () => { + const html = renderResults( + searchRecords( + { + records: [ + record, + { + id: "pack:probe", + nodeType: "Pack", + title: "Needle Pack", + page: "packs/probe.html", + bindings: [], + entries: [], + }, + { + id: "impl:probe", + nodeType: "CodeNode", + label: "Needle", + file: "src/Needle.ts", + line: 7, + page: null, + bindings: [{ edge: "satisfies", specId: "spec:probe.Find" }], + entries: [], + }, + { + id: "test:probe", + nodeType: "Anchor", + label: "Needle", + file: "test/probe.ts", + line: 12, + page: null, + bindings: [{ edge: "verifies", specId: "spec:probe.find" }], + entries: [], + }, + ], + }, + "needle", + ), + "needle", + 50, + ); + expect(html).toContain("Spec"); + expect(html).toContain("Pack"); + expect(html).toContain("Code unit"); + expect(html).toContain("Anchor"); + expect(html).toContain("stated defined"); + expect(html).toContain("floor ready"); + expect(html).toContain("src/Needle.ts:7"); + expect(html).toContain('text="src/Needle.ts:7"'); + expect(html).toContain('href="specs/probe._find.html"'); + expect(html).toContain("satisfies"); + expect(html).toContain("Find <script>"); + expect(html).not.toContain("<script>"); + expect(renderResults([], "<missing>", 50)).toContain("No node matches"); + }); + + it("requests a stamp only over HTTP and HTTPS and shows only its text fields", () => { + const builtAt = new Date(0).toISOString(); + expect(stampUrl("file:", "../")).toBeNull(); + expect(stampUrl("data:", "")).toBeNull(); + expect(stampUrl("https:", "../")).toBe("../stamp.json"); + expect(stampUrl("http:", "")).toBe("stamp.json"); + expect( + stampText({ + schemaVersion: "1", + corpusCommit: "abc", + engine: "sdp 0.0.0", + builtAt, + }), + ).toBe(`Corpus abc · sdp 0.0.0 · Built ${builtAt}`); + expect(stampText({ corpusCommit: 4 })).toBe(""); + }); + + it("loads the built classic bundle without imports and defines the six elements", () => { + const bundle = readFileSync(new URL("../dist/studio/studio.js", import.meta.url), "utf8"); + const defined: string[] = []; + expect(bundle).not.toMatch(/^\s*(?:import|export)\s/mu); + expect(bundle).not.toMatch(/\b(?:require\(|import\()/u); + expect(() => { + runInNewContext(bundle, { + HTMLElement: class { + connected = false; + }, + customElements: { + get: () => undefined, + define: (name: string) => { + defined.push(name); + }, + }, + }); + }).not.toThrow(); + expect(defined.sort()).toEqual([ + "sdp-copy", + "sdp-expander", + "sdp-filter", + "sdp-search", + "sdp-search-results", + "sdp-stamp", + ]); + }); + it("names a question copy control with the full entry address and updates it", () => { + const defined = new Map< + string, + new () => FakeNode & { + connectedCallback(): void; + attributeChangedCallback(): void; + } + >(); + class FakeNode { + ownerDocument = doc; + attributes = new Map<string, string>(); + children: FakeNode[] = []; + parentNode?: FakeNode; + textContent = ""; + append(node: FakeNode): void { + node.parentNode = this; + this.children.push(node); + } + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + querySelectorAll(): FakeNode[] { + return []; + } + addEventListener(): void { + /* No click is needed to inspect the accessible name. */ + } + } + const doc = { createElement: () => new FakeNode() }; + runInNewContext(readFileSync(new URL("../dist/studio/studio.js", import.meta.url), "utf8"), { + HTMLElement: FakeNode, + customElements: { + get: () => undefined, + define: ( + name: string, + ctor: new () => FakeNode & { + connectedCallback(): void; + attributeChangedCallback(): void; + }, + ) => defined.set(name, ctor), + }, + }); + const Copy = defined.get("sdp-copy"); + if (!Copy) throw new Error("Copy element missing"); + const copy = new Copy(); + copy.setAttribute("text", "spec:probe.all#question.choice"); + copy.connectedCallback(); + const control = copy.children[0]; + expect(control?.textContent).toBe("Copy"); + expect(control?.getAttribute("aria-label")).toBe("Copy spec:probe.all#question.choice"); + copy.setAttribute("text", "spec:probe.all#question.nextChoice"); + copy.attributeChangedCallback(); + expect(control?.getAttribute("aria-label")).toBe("Copy spec:probe.all#question.nextChoice"); + }); + it("uses the navigation search input, announces the empty prompt and reveals paging progress", () => { + const defined = new Map<string, new () => FakeNode & { connectedCallback(): void }>(); + const created: string[] = []; + const listeners = new Map<string, () => void>(); + class FakeNode { + ownerDocument = doc; + children: FakeNode[] = []; + textContent = ""; + className = ""; + hidden = false; + value = ""; + innerHTML = ""; + parentNode?: FakeNode; + append(...nodes: FakeNode[]): void { + for (const node of nodes) { + node.parentNode = this; + this.children.push(node); + } + } + replaceChildren(...nodes: FakeNode[]): void { + this.children = []; + this.append(...nodes); + } + setAttribute(): void { + /* Native attribute has no effect on this probe. */ + } + querySelectorAll(): FakeNode[] { + return []; + } + addEventListener(name: string, listener: () => void): void { + listeners.set(name, listener); + } + } + const doc = { + location: { hash: "" }, + defaultView: { + addEventListener(): void { + /* Only direct input events are used here. */ + }, + history: { + replaceState(): void { + /* The location is controlled by the probe. */ + }, + }, + }, + getElementById: () => ({ + textContent: JSON.stringify({ + records: Array.from({ length: 60 }, (_, i) => ({ + ...record, + id: `spec:probe.${String(i)}`, + })), + }), + }), + querySelector: () => input, + createElement: (name: string) => { + created.push(name); + return new FakeNode(); + }, + }; + const input = new FakeNode(); + runInNewContext(readFileSync(new URL("../dist/studio/studio.js", import.meta.url), "utf8"), { + HTMLElement: FakeNode, + customElements: { + get: () => undefined, + define: (name: string, ctor: new () => FakeNode & { connectedCallback(): void }) => + defined.set(name, ctor), + }, + }); + const Results = defined.get("sdp-search-results"); + if (!Results) throw new Error("Missing results element"); + const element = new Results(); + element.connectedCallback(); + expect(created).not.toContain("form"); + const status = element.children[0]; + expect(status?.textContent).toBe("Type a word to search."); + expect(status?.className).toBe("visually-hidden"); + input.value = "needle"; + listeners.get("input")?.(); + expect(element.children[1]?.innerHTML).toContain("60 nodes and 120 entries match"); + expect(status?.textContent).toBe("Showing 50 of 60 nodes"); + expect(status?.className).toBe(""); + listeners.get("click")?.(); + expect(status?.textContent).toBe("Showing 60 of 60 nodes"); + }); +}); diff --git a/test/studio-pages.test.ts b/test/studio-pages.test.ts new file mode 100644 index 00000000..a7344233 --- /dev/null +++ b/test/studio-pages.test.ts @@ -0,0 +1,722 @@ +import { readFileSync } from "node:fs"; +import { posix } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { ref, specTest, testAnchorId } from "@libar-dev/software-delivery-protocol"; +import { extract } from "../src/extract/index.js"; +import { schemaVersion } from "../src/graph/schema.js"; +import type { GraphSchema, PrimitiveNode } from "../src/graph/schema.js"; +import { createReader } from "../src/reader/reader.js"; +import { createStudioData } from "../src/projections/studio/data.js"; +import { renderStudio } from "../src/projections/studio/pages/index.js"; +import type { StudioPage } from "../src/projections/studio/pages/index.js"; +import { pagePath } from "../src/projections/studio/paths.js"; +import { text } from "../src/projections/studio/pages/text.js"; +import { renderResults, searchRecords } from "../src/studio-elements/search-results.js"; + +const anchor = specTest({ + id: testAnchorId("test:protocol.studio-pages"), + verifies: [ + ref("spec:consumers.spec-studio.shell"), + ref("spec:consumers.spec-studio.index-page"), + ref("spec:consumers.spec-studio.pack-page"), + ref("spec:consumers.spec-studio.spec-page.core"), + ref("spec:consumers.spec-studio.search"), + ], +}); +void anchor; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const primitive = (id: string, changes: Partial<PrimitiveNode> = {}): PrimitiveNode => ({ + id, + nodeType: "Primitive", + claim: "declared", + specKind: "behavior", + altitude: "story", + readiness: "defined", + title: id, + file: "design/probe.sdp.md", + deliveryFacts: [], + sections: { + intent: { outcome: "Read the design" }, + behavior: { + rules: ["A rule"], + examples: [{ given: ["Before"], when: ["Action"], then: ["After"] }], + }, + }, + ...changes, +}); +const probe: GraphSchema = { + schemaVersion, + nodes: [ + primitive("spec:probe.all", { + title: "Read <script> safely", + narrative: "**Plain** `inline` spec:probe.target#design.alpha and spec:probe.absent. ", + sections: { + intent: { + outcome: "The outcome", + openQuestions: [ + { + key: "choice", + question: "Which choice?", + blocking: false, + decider: "owner", + links: ["spec:probe.target#design.alpha"], + provisional: "Keep this reading", + trigger: "A reader asks", + }, + ], + }, + behavior: { + rules: ["First rule", "Third rule"], + flows: ["Second flow"], + ruleKeys: { first: "rules[0]", second: "flows[0]" }, + examples: [{ given: ["Given text"], when: ["When text"], then: ["Then text"] }], + exampleSpace: { + given: ["Given {name:string}"], + when: ["When {action:string}"], + then: ["Then {outcome:string}"], + }, + }, + constraints: [{ statement: "A constraint", target: "< 2 ms", flavor: "performance" }], + model: { terms: { Order: "A model term" } }, + design: { + zeta: "Last named first", + alpha: "`<script>` spec:probe.target#design.alpha", + object: { value: "Object text" }, + }, + decision: { + context: "Decision context", + decision: "Choose this", + rationale: ["Because"], + alternatives: ["Another choice"], + consequences: ["One consequence"], + }, + verification: { mode: "reviewed", criteria: ["Read the whole page"] }, + ui: { layout: "A layout" }, + }, + }), + primitive("spec:probe.target", { + sections: { + intent: { outcome: "Target" }, + behavior: { rules: ["Target rule"] }, + design: { alpha: "Target entry" }, + }, + }), + primitive("spec:probe.dishonest", { + readiness: "ready", + sections: { intent: { outcome: "Incomplete" } }, + }), + { + id: "pack:probe", + nodeType: "Pack", + claim: "declared", + title: "Probe Pack", + file: "design/probe.pack.sdp.md", + framing: "The Pack framing", + members: ["spec:probe.target", "spec:probe.all", "spec:probe.dishonest", "spec:probe.absent"], + modelRefs: ["spec:probe.target"], + }, + { + id: "component:probe", + nodeType: "CodeNode", + claim: "anchored", + file: "src/probe.ts", + line: 1, + layer: "domain", + context: "probe", + }, + { + id: "impl:probe", + nodeType: "CodeNode", + claim: "anchored", + label: "The implementation", + file: "src/probe.ts", + line: 2, + role: "service", + }, + { id: "test:probe", nodeType: "Anchor", claim: "anchored", file: "test/probe.ts", line: 3 }, + { id: "oracle:probe", nodeType: "Anchor", claim: "anchored", file: "test/probe.ts", line: 4 }, + ], + edges: [ + { + from: "spec:probe.all", + to: "spec:probe.target", + type: "dependsOn", + claim: "declared", + reason: "It supplies the entry", + exercises: ["first"], + }, + ...["spec:probe.target", "spec:probe.all", "spec:probe.dishonest", "spec:probe.absent"].map( + (from) => ({ + from, + to: "pack:probe", + type: "belongsTo" as const, + claim: "declared" as const, + }), + ), + { from: "impl:probe", to: "spec:probe.all", type: "satisfies", claim: "anchored" }, + { from: "impl:probe", to: "component:probe", type: "memberOf", claim: "anchored" }, + { from: "test:probe", to: "spec:probe.all", type: "verifies", claim: "anchored" }, + { from: "oracle:probe", to: "spec:probe.all", type: "models", claim: "anchored" }, + ], + locations: [ + { spec: "spec:probe.all", file: "design/probe.sdp.md", entry: "design.zeta", line: 40 }, + { spec: "spec:probe.all", file: "design/probe.sdp.md", entry: "design.alpha", line: 41 }, + { spec: "spec:probe.all", file: "design/probe.sdp.md", entry: "design.object", line: 42 }, + { spec: "spec:probe.all", file: "design/probe.sdp.md", entry: "ui.layout", line: 50 }, + { spec: "spec:probe.all", file: "design/probe.sdp.md", entry: "rule[0]", line: 30 }, + { spec: "spec:probe.all", file: "design/probe.sdp.md", entry: "flow[0]", line: 31 }, + { spec: "spec:probe.all", file: "design/probe.sdp.md", entry: "rule[1]", line: 32 }, + { spec: "spec:probe.all", file: "design/probe.sdp.md", entry: "question[0]", line: 20 }, + ], +}; +const data = createStudioData(createReader(probe)); +const pages = renderStudio(data); +const html = (id: string): string => { + const page = pages.find((p) => p.path === (pagePath(id) ?? id)); + if (!page) throw new Error(`Missing page ${id}`); + return page.content; +}; +const decode = (s: string): string => + s + .replaceAll("&", "&") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">"); +const summaryItems = (page: string): string[] => + [ + ...(/<ul class="summary">(.*?)<\/ul>/su.exec(page)?.[1] ?? "").matchAll(/<li>(.*?)<\/li>/gsu), + ].map((m) => m[1] ?? ""); +const rail = (page: string): string => + /<nav class="rail"[^>]*>(.*?)<\/nav>/su.exec(page)?.[1] ?? ""; + +function checkLinks(set: readonly StudioPage[]): void { + const byPath = new Map(set.map((page) => [page.path, page.content])); + for (const page of set) { + const ids = [...page.content.matchAll(/\bid="([^"]+)"/gu)].map((m) => decode(m[1] ?? "")); + expect(new Set(ids).size, `${page.path} duplicate ids`).toBe(ids.length); + for (const match of page.content.matchAll(/\bhref="([^"]+)"/gu)) { + const href = decode(match[1] ?? ""); + expect(href, `${page.path}: ${href}`).not.toMatch(/^(?:[a-z]+:|\/)/iu); + const [path = "", fragment] = href.split("#"); + const target = + path === "" ? page.path : posix.normalize(posix.join(posix.dirname(page.path), path)); + if (target === "assets/studio.css") continue; + const content = byPath.get(target); + expect(content, `${page.path}: ${href}`).toBeDefined(); + if (fragment && !(target === "search.html" && fragment.startsWith("q="))) { + expect(content, `${page.path}: ${href}`).toContain(`id="${decodeURIComponent(fragment)}"`); + } + } + } +} + +describe("Studio static pages", () => { + it("frames every page with a skip link, labelled search, rail, canvas, classic script and stamp", () => { + for (const page of pages) { + expect(page.content).toContain('class="skip-link" href="#main"'); + expect(page.content).toContain('<main id="main"'); + expect(page.content).toContain('<sdp-search page-root="'); + expect(page.content).toContain('for="q"'); + expect(page.content).toContain('<sdp-stamp page-root="'); + expect(page.content).toMatch(/<script defer src="(?:\.\.\/)?assets\/studio.js"><\/script>/u); + expect(page.content).toContain(`${String(data.rail.findings.errors)} errors`); + } + expect(html("pack:probe")).toContain('href="#spec:probe.target"'); + expect(html("spec:probe.all")).toContain('aria-current="page"'); + }); + it("lists example-space owners only in Pack and Spec rails", () => { + for (const id of ["index.html", "search.html"]) + expect(rail(html(id))).not.toContain("Example spaces"); + for (const id of ["pack:probe", "spec:probe.all"]) + expect(rail(html(id))).toContain("Example spaces</h2><ul><li><a"); + }); + it("shows a keyed question once with a copy action naming its full address", () => { + const question = + /<li id="question.choice">(.*?)<\/li>/su.exec(html("spec:probe.all"))?.[1] ?? ""; + expect(question).toContain("<code>question.choice</code>"); + expect(question).toContain('text="spec:probe.all#question.choice"'); + expect(question).not.toContain("<code>spec:probe.all#question.choice</code>"); + expect(question).toContain("Which choice?"); + expect(question).toContain("line 20"); + }); + it("opens the index with counts, four task routes, every facet and stable section ids", () => { + const index = html("index.html"); + for (const id of ["packs", "specs", "findings"]) expect(index).toContain(`id="${id}"`); + for (const label of [ + "Read a Pack's design", + "Find a Spec, entry or file by a word", + "Narrow the Specs", + "Read the findings", + ]) + expect(decode(index)).toContain(label); + for (const name of [ + "kind", + "altitude", + "stated", + "floor", + "implementation", + "verifier", + "pack", + ]) + expect(index).toContain(`data-facet="${name}"`); + expect(index).toContain("3 Specs"); + expect(index).toContain("1 Pack"); + expect(index).toContain("2 Specs stated above their floor"); + expect(index).toContain("form hidden"); + expect(index).toContain("data-row"); + expect(index).toContain("3 of 3 shown"); + }); + it("gives each Spec row one rung badge and binding words", () => { + const rows = [...html("index.html").matchAll(/<li[^>]*data-row[^>]*>(.*?)<\/li>/gsu)]; + expect(rows).toHaveLength(3); + for (const row of rows) { + expect(row[1]?.match(/class="badge rung-/gu)).toHaveLength(1); + expect(row[1]).toContain("implementation binding"); + expect(row[1]).toContain("verifier binding"); + expect(row[1]).not.toMatch(/has-verifier|implemented/gu); + } + }); + it("puts Pack totals before framing and folds holds into that summary", () => { + const pack = html("pack:probe"); + expect(pack.indexOf("Where the design stands")).toBeLessThan(pack.indexOf("<h2>Framing</h2>")); + expect(pack).not.toContain("<h2>Holds</h2>"); + expect(pack).toContain("nothing blocks"); + expect(pack).toMatch(/unmet clauses?/u); + expect(pack).toContain('href="#spec:probe.dishonest"'); + expect(pack).toContain("No members with a design reference"); + expect(pack).toContain("manifest names an id the graph does not hold"); + const summary = summaryItems(pack); + const held = data.packs[0]?.context.members.find((m) => m.id === "spec:probe.target"); + expect(summary[1]).toContain('href="#spec:probe.target"'); + expect(summary[1]).toContain(`(${String(held?.statedNextRungFailures?.length)} unmet clause`); + expect(summary[2]).toContain('href="#spec:probe.all"'); + }); + it("leaves member names out when every member waits only for its author", () => { + const all = primitive("spec:probe.waiting"); + const waiting = + renderStudio( + createStudioData( + createReader({ + schemaVersion, + nodes: [ + all, + { + id: "pack:waiting", + nodeType: "Pack", + claim: "declared", + file: "waiting.pack.sdp.md", + members: [all.id], + }, + ], + edges: [{ from: all.id, to: all.id, type: "dependsOn", claim: "declared" }], + }), + ), + ).find((p) => p.path === "packs/waiting.html")?.content ?? ""; + expect(decode(summaryItems(waiting)[2] ?? "")).toBe( + "1 member waiting only for their author's statement.", + ); + expect(summaryItems(waiting)[2]).not.toContain("href"); + }); + it("agrees each boundary verb with its count", () => { + const member = primitive("spec:probe.member"); + const one = (id: string, type: "dependsOn" | "verifies" | "supersedes") => ({ + node: primitive(id), + edge: { from: id, to: member.id, type, claim: "declared" as const }, + }); + const outside = [ + one("spec:probe.rester", "dependsOn"), + one("spec:probe.verifier", "verifies"), + one("spec:probe.successor", "supersedes"), + ]; + const page = + renderStudio( + createStudioData( + createReader({ + schemaVersion, + nodes: [ + member, + ...outside.map((o) => o.node), + { + id: "pack:boundary", + nodeType: "Pack", + claim: "declared", + file: "boundary.pack.sdp.md", + members: [member.id], + }, + ], + edges: outside.map((o) => o.edge), + }), + ), + ).find((p) => p.path === "packs/boundary.html")?.content ?? ""; + expect(decode(summaryItems(page).at(-1) ?? "")).toBe( + "No outside Specs the Pack rests on; 1 outside Spec rests on the Pack; 1 outside Spec verifies it; 1 outside Spec supersedes it.", + ); + }); + it("shows only the Spec's own Packs in its rail and every Pack on the index", () => { + const rendered = renderStudio( + createStudioData( + createReader({ + ...probe, + nodes: [ + ...probe.nodes, + { + id: "pack:other", + nodeType: "Pack", + claim: "declared", + title: "Other Pack", + file: "other.pack.sdp.md", + members: ["spec:probe.target"], + }, + ], + }), + ), + ); + expect( + rail(rendered.find((p) => p.path === "specs/probe.all.html")?.content ?? ""), + ).not.toContain("Other Pack"); + expect(rail(rendered.find((p) => p.path === "index.html")?.content ?? "")).toContain( + "Other Pack", + ); + }); + it("asserts every index summary sentence including fact-based verifier counts", () => { + const verifier = data.specs.find((s) => s.id === "spec:probe.all")?.verifiers[0]; + if (!verifier) throw new Error("Missing probe verifier"); + const rendered = renderStudio({ + ...data, + specs: data.specs.map((s) => + s.id === "spec:probe.target" + ? { + ...s, + verifiers: [{ ...verifier, via: "example" as const, enabled: false }], + } + : s, + ), + findings: [ + { family: "conformance", validatorId: "probe", severity: "error", message: "error" }, + { family: "conformance", validatorId: "probe", severity: "warning", message: "warning" }, + { family: "conformance", validatorId: "probe", severity: "warning", message: "warning" }, + ], + }); + expect(data.specs.find((s) => s.id === "spec:probe.all")?.verifiers).toHaveLength(1); + expect( + summaryItems(rendered.find((p) => p.path === "index.html")?.content ?? "").map(decode), + ).toEqual([ + "3 Specs and 1 Pack in this corpus.", + "No Specs stated idea.", + "No Specs stated scoped.", + "2 Specs stated defined.", + "1 Spec stated ready.", + "2 Specs stated above their floor.", + "1 open question and no blocking questions.", + "1 Spec with an implementation binding and 1 Spec with a verifier binding.", + "1 error and 2 warnings.", + ]); + }); + it("renders code spans in titles on every page and strips them in document titles", () => { + const title = "Use `safe <&>` with spec:probe.target"; + const rendered = renderStudio( + createStudioData( + createReader({ + ...probe, + nodes: probe.nodes.map((n) => + n.id === "spec:probe.all" || n.id === "pack:probe" ? { ...n, title } : n, + ), + }), + ), + ); + for (const path of ["specs/probe.all.html", "packs/probe.html", "index.html"]) { + const page = rendered.find((p) => p.path === path)?.content ?? ""; + expect(page).toContain("Use <code>safe <&></code> with spec:probe.target"); + expect(page).not.toContain("`safe"); + } + expect(rendered.find((p) => p.path === "specs/probe.all.html")?.content).toContain( + "<title>Use safe <&> with spec:probe.target · Spec Studio", + ); + expect(html("index.html")).toContain("Spec Studio"); + }); + it("orders rung filters by the ladder and uses none everywhere for an unmet idea floor", () => { + const graph: GraphSchema = { + schemaVersion, + nodes: [ + ...["ready", "defined", "scoped", "idea"].map((readiness) => + primitive(`spec:probe.${readiness}`, { + readiness: readiness as PrimitiveNode["readiness"], + }), + ), + primitive("spec:probe.empty", { title: "", sections: {} }), + { + id: "pack:empty", + nodeType: "Pack", + claim: "declared", + file: "empty.pack.sdp.md", + members: ["spec:probe.empty"], + }, + ], + edges: ["ready", "defined", "scoped", "idea"].map((r) => ({ + from: `spec:probe.${r}`, + to: `spec:probe.${r}`, + type: "dependsOn", + claim: "declared", + })), + }; + const page = + renderStudio(createStudioData(createReader(graph))).find((p) => p.path === "index.html") + ?.content ?? ""; + const options = (name: string) => + [ + ...( + new RegExp(``, "su").exec(page)?.[1] ?? "" + ).matchAll(/