diff --git a/.agents/skills/sdp-agent-surface/SKILL.md b/.agents/skills/sdp-agent-surface/SKILL.md index 1fa94729..2937a6fa 100644 --- a/.agents/skills/sdp-agent-surface/SKILL.md +++ b/.agents/skills/sdp-agent-surface/SKILL.md @@ -85,10 +85,10 @@ The public projection publishers are `sdp view`, `sdp census`, `sdp mermaid`, an In this source checkout, use `npm run generate:self-hosting` or `npm run check:self-hosting` when all four roots must be published or certified together. -The catalog contains twenty-four ready-made bodies in `docs/agent-surface/recipes.md` in the +The catalog contains twenty-six ready-made bodies in `docs/agent-surface/recipes.md` in the Protocol repository and `node_modules/@libar-dev/software-delivery-protocol/docs/agent-surface/recipes.md` in an adopter. -Recipes 1-24 each open under a numbered heading that names the recipe. Every body there runs +Recipes 1-26 each open under a numbered heading that names the recipe. Every body there runs verbatim and a test proves it. Start from a recipe; adapt it in place. For structural questions, use component membership, uses fan-in and fan-out, structural @@ -99,18 +99,24 @@ dependsOn and refines), or the planning slice to see refinement and dependency n decisions, bound components, and entry points before editing. For a table you would otherwise keep by hand, run a register recipe each time you need it: the -open-question register (recipe 20) for every open question and its blocking flag, dependency -footing (recipe 21) for what one Spec rests on, and the mention audit (recipe 22) for Spec ids in -prose. An entry address, `spec:#design.` or `spec:#ui.`, names one keyed entry -of a Spec's Design or UI section; it belongs in prose, and `sdp validate` reports an address whose -Spec or key does not exist as an error. When you hold a key or a term and need the entry that -carries it, use entry search (recipe 23): it matches whole tokens and names the entry, where -concept search stops at the section. It also gives the entry's address when the entry is a +open-question register (recipe 20) for every open question with its blocking flag and its key, +dependency footing (recipe 21) for what one Spec rests on, and the mention audit (recipe 22) for +Spec ids in prose. An entry address, `spec:#design.` or `spec:#ui.`, names one +keyed entry of a Spec's Design or UI section, and `spec:#question.` names the open +question whose marker carries that key; it belongs in prose, and `sdp validate` reports an address +whose Spec or key does not exist as an error. When you hold addresses written outside the +Specs, in a register, a test, or a page, address resolution (recipe 25) gives one row per +address: whether it resolves and, when it does not, why. When you hold a key or a term and need +the entry that carries it, use entry search (recipe 23): it matches whole tokens and names the +entry, where concept search stops at the section. It also gives the entry's address when the entry is a top-level Design or UI key that starts with a lowercase ASCII letter and goes on in ASCII letters -and digits, other than `description`. Every other entry, a lawful `"01"` key included, gets -`address: null`. When a Design Review or a test author needs every signature a corpus has pinned, -use pinned declarations (recipe 24): it lists each keyed Design entry whose value opens with a -code span, with the Spec, the key and the span content as authored. +and digits, other than `description`, and when the entry is the text of an open question that +carries a key. Every other entry, a lawful `"01"` key included, gets `address: null`. When a +Design Review or a test author needs every signature a corpus has pinned, use pinned declarations +(recipe 24): it lists each keyed Design entry whose value opens with a code span, with the Spec, +the key and the span content as authored. To see where Specs rest on each other or on themselves +through `dependsOn`, run dependency cycles (recipe 26): it lists each such set with one closed +path through it, and reports without refusing. Reach for the files only when you need the authored prose itself — the exact words to edit. diff --git a/.agents/skills/sdp-authoring/SKILL.md b/.agents/skills/sdp-authoring/SKILL.md index 2287f948..47e816e9 100644 --- a/.agents/skills/sdp-authoring/SKILL.md +++ b/.agents/skills/sdp-authoring/SKILL.md @@ -121,7 +121,9 @@ takes its entries only. Prose after a section's first entry, fence, or H3 is ref | `## Verification — ` | plain entries, one criterion each; the mode is `manual`, `reviewed`, `contract`, or `executable`, after an em dash | Intent may end with an optional `### Open questions`, the only H3 the body accepts. Each of its -entries opens with `[blocking]` or `[non-blocking]`. On an example, one `gwt` fence closes Intent. +entries opens with `[blocking]` or `[non-blocking]`. To cite a question from prose, give it a +lower-camel key, unique in the Spec, after `#` inside the marker: `[blocking #aggregateReach]` is +addressed as `spec:#question.aggregateReach`. On an example, one `gwt` fence closes Intent. A Spec carries at most one of Behavior, Rule, Workflow, and Contract. Leading prose may stand under that owner or under Example space, not both. In Rule, Contract, Workflow, and Verification, open diff --git a/.agents/skills/sdp-sessions/SKILL.md b/.agents/skills/sdp-sessions/SKILL.md index 4dedf99c..06f8ceb9 100644 --- a/.agents/skills/sdp-sessions/SKILL.md +++ b/.agents/skills/sdp-sessions/SKILL.md @@ -50,12 +50,13 @@ carrier, `sdp new spec`, the `validate --watch` loop, and the one-kind rule. Use promotion preflight (recipe 9) on the target, the planning slice (recipe 19) for neighborhood and shaping decisions, dependency footing (recipe 21) for the stated and derived readiness of -every Spec the target rests on, and readiness divergence (recipe 7) across the corpus. Resolve the -target's blocking open questions and review the carrying Specs. The open-question 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. +every Spec the target rests on, and readiness divergence (recipe 7) across the corpus. Use +dependency cycles (recipe 26) to see whether the target rests on itself through `dependsOn`. +Resolve the target's blocking open questions and review the carrying Specs. The open-question +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. ### Implement @@ -73,8 +74,9 @@ a binding, not a passing or live system. For a Pack, use the Pack review backbone (recipe 5) and warn-level signals (recipe 8). Without a Pack, use the target Spec context (recipe 3) with warn-level signals (recipe 8). To review a change, take the scope from changed-file blast radius (recipe 4) over the diff, then run the -mention audit (recipe 22) with that scope as its parameter. When reviewing component, -architecture, or projection questions, use structural neighborhood (recipe 14), census +mention audit (recipe 22) with that scope as its parameter. Use address resolution (recipe 25) on +the entry addresses a register, a test, or a page cites outside the Specs. When reviewing +component, architecture, or projection questions, use structural neighborhood (recipe 14), census structural coverage (recipe 15), the projection-coverage upper bound (recipe 16), the architecture map (recipe 17), and the decision map (recipe 18). Review findings and gaps as data; the review never becomes a workflow gate. diff --git a/AGENTS.md b/AGENTS.md index fdcfd662..9390d3c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ Progressive disclosure — start at the top, follow the pointers down. | Look here | What you get | Read | |---|---|---| | `CONTEXT.md` (repo root) | **the vocabulary** — the ratified lean glossary (terms · relations · a worked dialogue · flagged ambiguities); sole source of truth for terminology; the model exposition lives in the Specs under `specs/` and in the surviving concept docs | **first, always** | -| `.agents/skills/` + `docs/agent-surface/recipes.md` | **the agent on-ramps** — three repository-owned skills: `sdp-agent-surface` (reading the graph), `sdp-authoring` (authoring intent), `sdp-sessions` (advisory delivery-session routing), also exposed to Claude through the `.claude/skills` symlink; plus the twenty-four runnable `sdp q` bodies | **mandatory** — after `CONTEXT.md`, load the matching skill before any corpus question, Spec authoring, or delivery-session routing; see "Query the graph first" | +| `.agents/skills/` + `docs/agent-surface/recipes.md` | **the agent on-ramps** — three repository-owned skills: `sdp-agent-surface` (reading the graph), `sdp-authoring` (authoring intent), `sdp-sessions` (advisory delivery-session routing), also exposed to Claude through the `.claude/skills` symlink; plus the twenty-six runnable `sdp q` bodies | **mandatory** — after `CONTEXT.md`, load the matching skill before any corpus question, Spec authoring, or delivery-session routing; see "Query the graph first" | | `specs/` | **the self-hosted corpus** — the Protocol's own Specs in its own carrier (families: `model` · `extraction` · `validation` · `carrier` · `consumers` · `protocol` · `observation` · `decisions`, plus the self-hosting Pack); the primary carrier of intended truth | when design truth is in question — but query it through `sdp q` first, then read the carrying Spec | | `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, consumers, roadmap; the core model, the one graph, and validation & honesty dissolved into the `model.*`, `extraction.*`, and `validation.*` Spec families — locate any of them with concept search (recipe 6) | to know *how* it is designed | @@ -132,7 +132,7 @@ The full CLI surface is `sdp build · validate · view · census · mermaid · g 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 -twenty-four runnable recipe bodies live in `docs/agent-surface/recipes.md` (each executed as written by +twenty-six 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 on-ramps. In this checkout, always go through diff --git a/CONTEXT.md b/CONTEXT.md index 1b1d08f8..32ceef65 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -225,10 +225,11 @@ delivery fact, workflow state, or graph state. locked ledger when the record states `ready`). **mention**: a `spec:` id written in a Spec's narrative or section text, outside the `gwt` and `gwt-vocabulary` fences; checked for resolution and for a backing declared relation in either direction, and it mints no edge (aliases to avoid: - "reference" · "citation" · "link"). **entry address**: a Spec id followed by `#`, the open section's - name (`design` or `ui`), a dot, and the entry's lower-camel ASCII key, naming one keyed entry of - that Spec; admitted in prose and by `parseId` and refused in every id slot, so a Spec's own id - never carries `#`, and checked in prose by `spec:validation.prose-mentions`, which is implemented + "reference" · "citation" · "link"). **entry address**: a Spec id followed by `#`, a section name + (`design`, `ui`, or `question`), a dot, and the entry's lower-camel ASCII key, naming one keyed + entry of that Spec's open section, or one of its open questions that carries that key; admitted + in prose and by `parseId` and refused in every id slot, so a Spec's own id never carries `#`, + and checked in prose by `spec:validation.prose-mentions`, which is implemented and still states `defined` (aliases to avoid: "anchor", reserved for the in-code binding · "fragment" · "sub-id"). - ~~Candidate vocabulary from the executable-spec exploration: *notation* · *carrier*~~ — **ratified diff --git a/README.md b/README.md index 8a7cd242..3365f576 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ pnpm --silent sdp:q 'return g.specs().length' ``` The `sdp:q` script supplies this repository's required fixture exclusions. Use the -[twenty-four graph-first recipes](docs/agent-surface/recipes.md) for backlog, drift, verifier, impact, +[twenty-six graph-first recipes](docs/agent-surface/recipes.md) for backlog, drift, verifier, impact, Pack, readiness, promotion, open-question, dependency, mention, and entry-search queries. The [architecture walkthrough](docs/agent-surface/architecture.md) follows shared policy, @@ -147,4 +147,4 @@ for carrier support. The package installs three agent on-ramps as `SKILL.md` files under `node_modules/@libar-dev/software-delivery-protocol/.agents/skills/`. Use `sdp-agent-surface` to read the graph, `sdp-authoring` to author intent, and `sdp-sessions` for advisory delivery-session -routing. The same package includes the twenty-four recipe bodies at `docs/agent-surface/recipes.md`. +routing. The same package includes the twenty-six recipe bodies at `docs/agent-surface/recipes.md`. diff --git a/check-prose-schema.mjs b/check-prose-schema.mjs index 2fd53160..c54a3c36 100644 --- a/check-prose-schema.mjs +++ b/check-prose-schema.mjs @@ -23,7 +23,7 @@ for (const section of [ ]) { expectContains("src/model/sections.ts", section, "missing section description owner"); } -expectContains("src/graph/schema.ts", 'schemaVersion = "0.6.0"', "missing 0.6.0 schema literal"); +expectContains("src/graph/schema.ts", 'schemaVersion = "0.7.0"', "missing 0.7.0 schema literal"); expectContains("src/graph/schema.ts", "readonly narrative?: string;", "missing graph narrative"); expectContains("src/reader/reader.ts", "readonly narrative?: string;", "missing Reader narrative"); expectContains( @@ -47,7 +47,7 @@ expectContains( ); expectContains( "specs/extraction/schema-versioning.declared-version.sdp.md", - '{schemaVersion: "0.6.0"}', + '{schemaVersion: "0.7.0"}', "missing declared-version example literal", ); expectContains( @@ -62,7 +62,7 @@ expectContains( ); expectContains( "docs/concept/06-consumers-and-projections.md", - "Design Review now renders `Spec.narrative` and the seven approved section descriptions from those Reader/graph values (schema `0.6.0`), with no source reparse and stable omission when prose is absent.", + "Design Review now renders `Spec.narrative` and the seven approved section descriptions from those Reader/graph values (schema `0.7.0`), with no source reparse and stable omission when prose is absent.", "missing landed prose projection claim", ); diff --git a/contract-dependent-suites.mjs b/contract-dependent-suites.mjs index aff1db8d..96604c6c 100644 --- a/contract-dependent-suites.mjs +++ b/contract-dependent-suites.mjs @@ -24,6 +24,15 @@ export const contractDependentSuites = [ contracts: "generated/contracts", generation: "npm run generate:self-hosting", testPaths: [ + "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", + "test/self-hosting-open-question-keys.test.ts", + "test/model.open-question-keys.keyed-question-resolves.test.generated.ts", + "test/model.open-question-keys.renamed-key-breaks.test.generated.ts", + "test/model.open-question-keys.repeated-key-refused.test.generated.ts", + "test/self-hosting-pack-member-order.test.ts", + "test/extraction.pack-member-order.manifest-order-kept.test.generated.ts", "test/carrier.gherkin-authoring.authored-fact-refused.test.generated.ts", "test/carrier.gherkin-authoring.description-location-refused.test.generated.ts", "test/carrier.gherkin-authoring.duplicate-surface-refused.test.generated.ts", diff --git a/docs/agent-surface/recipes.md b/docs/agent-surface/recipes.md index da27e5e2..b2e0bf1f 100644 --- a/docs/agent-surface/recipes.md +++ b/docs/agent-surface/recipes.md @@ -32,11 +32,11 @@ pnpm exec sdp q '' --root PATH --exclude PATH --exclude PATH `--root PATH` picks the extraction root (default: the working directory) and `--exclude` is repeatable for root-relative path prefixes. `PATH` is a placeholder, not a literal directory. -**Some recipes open with a parameter.** Recipes 3, 6, 9, 14, 19, 21, 22, and 23 take their -subject on the opening `const` line(s): a Spec id, a search term, a component id, or a list of -Spec ids. Those lines name *this* repository's corpus so every body runs as written here (the -recipe check executes each one verbatim); in your own corpus, substitute your subject on that -line before running. A Spec id or component id absent from the graph returns `{ found: false }` rather than failing. +**Some recipes open with a parameter.** Recipes 3, 6, 9, 14, 19, 21, 22, 23, and 25 take their +subject on the opening `const` line(s): a Spec id, a search term, a component id, a list of +Spec ids, or a list of entry addresses. Those lines name *this* repository's corpus so every body +runs as written here (the recipe check executes each one verbatim); in your own corpus, +substitute your subject on that line before running. A Spec id or component id absent from the graph returns `{ found: false }` rather than failing. **Recipe 4 is different.** Recipe 4 filenames travel via `SDP_CHANGED_FILES_JSON`; callers never substitute filenames into the JavaScript fence. Keep its query body static and pass changed paths @@ -382,6 +382,11 @@ if (context === undefined) { const rungs = ["idea", "scoped", "defined", "ready"]; const reached = context.derivedReadiness ?? "none"; const reachedIndex = reached === "none" ? -1 : rungs.indexOf(reached); +const shape = (failure) => ({ + clauseId: failure.clauseId, + description: failure.description, + ...(failure.targets === undefined ? {} : { targets: failure.targets }), +}); return { id, @@ -389,11 +394,10 @@ return { statedReadiness: context.statedReadiness, floorReached: reached, nextRung: rungs[reachedIndex + 1] ?? null, - currentFloorFailures: context.floorFailures.map((failure) => ({ - clauseId: failure.clauseId, - description: failure.description, - })), + currentFloorFailures: context.floorFailures.map(shape), firstUnmetClause: context.floorFailures[0]?.clauseId ?? null, + nextRungFailures: context.nextRungFailures.map(shape), + nextRungFirstUnmetClause: context.nextRungFailures[0]?.clauseId ?? null, promotionRequiresHumanStatement: true, }; ``` @@ -401,6 +405,8 @@ return { The `id` line is the recipe's parameter — substitute the Spec whose promotion you are weighing. An empty `currentFloorFailures` list says the stated rung is honest. It does not confer the next rung, and `floorReached` above the stated rung is information rather than an automatic edit. +`nextRungFailures` are the unmet clauses of `nextRung`, and a typed-dependency failure lists its +`targets`; neither confers the rung. ## 10. Declared versus enabled verifiers @@ -1098,7 +1104,19 @@ for (const spec of g.specs()) { bad(`openQuestions[${index}]`, "Expected a question and boolean blocking flag"); return; } - questions.push({ blocking: typeof entry === "string" ? false : entry.blocking ?? false, question }); + if ( + typeof entry !== "string" && + "key" in entry && + (typeof entry.key !== "string" || !/^[a-z][A-Za-z0-9]*$/u.test(entry.key)) + ) { + bad(`openQuestions[${index}]`, "Expected a lower-camel question key"); + return; + } + questions.push({ + blocking: typeof entry === "string" ? false : entry.blocking ?? false, + question, + key: typeof entry === "string" ? null : entry.key ?? null, + }); }); if (questions.length === 0) continue; rows.push({ @@ -1128,15 +1146,17 @@ return { ``` Each row is one Spec that records open questions under Intent: its stated readiness, -`totals.blocking`, and the questions in authored order with their flags. Specs that hold a +`totals.blocking`, and the questions in authored order with their flags and keys. A question's +`key` is the one its marker carries, `[blocking #aggregateReach]`, which prose cites as +`spec:#question.aggregateReach`; a question without a key has `key: null`. Specs that hold a blocking question come first, and each group keeps Spec id order. `totals` reports the question and Spec counts, so the size of the register never needs a second query or a number copied into prose. The recipe lists and does not judge: a blocking question holding its Spec below `defined` is the readiness floor's clause, which recipe 9 names for one Spec. A question authored as bare prose or as an object with no `blocking` flag in a TypeScript carrier reads as non-blocking. `malformed` reports collections that are not lists, entries without non-empty question text, -and entries whose `blocking` flag is present but is not a boolean. Those entries do not count -as questions. +entries whose `blocking` flag is present but is not a boolean, and entries whose `key` is present +but is not a lower-camel string. Those entries do not count as questions. ## 21. Dependency footing @@ -1232,7 +1252,7 @@ const escapePattern = /\\([!-/:-@[-`{-~])/gu; const idPattern = /(?|]|[^\p{ASCII}\p{White_Space}\p{Pd}\p{Ps}\p{Pe}\p{Pi}\p{Pf}\p{Po}\p{S}\p{Z}])*/gu; const mentionPattern = - /^spec:[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)*(?:#(design|ui)\.([a-z][A-Za-z0-9]*))?$/u; + /^spec:[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)*(?:#(design|ui|question)\.([a-z][A-Za-z0-9]*))?$/u; const specIds = new Set(g.specs().map((spec) => spec.id)); const declared = new Map(); @@ -1318,11 +1338,16 @@ for (const spec of g.specs()) { continue; } const [, section, key] = parsed; - if ( - section !== undefined && - (key === "description" || !Object.hasOwn(g.specContext(to)?.sections?.[section] ?? {}, key)) - ) - record(unresolvedPairs, spec.id, token, at, "entry"); + if (section !== undefined) { + const sections = g.specContext(to)?.sections ?? {}; + const questions = sections.intent?.openQuestions; + const holds = + section === "question" + ? Array.isArray(questions) && + questions.some((entry) => typeof entry === "object" && entry !== null && entry.key === key) + : key !== "description" && Object.hasOwn(sections[section] ?? {}, key); + if (!holds) record(unresolvedPairs, spec.id, token, at, "entry"); + } if (to !== spec.id) record(pairs, spec.id, to, at); } } @@ -1375,9 +1400,12 @@ A token equal to the scanning Spec's own id is skipped; its own entry addresses Each row keeps every distinct location in `at` as `{ section, entry }`, with zero-based indexes inside `entry`, and counts token occurrences in `totals.occurrences`. `unresolved` groups by mentioning Spec and token, with `reason: "malformed"` for a refused id or -address, `"spec"` for an absent Spec, and `"entry"` for an absent own section key or `description`. +address, `"spec"` for an absent Spec, and `"entry"` for an absent own section key, `description` +in Design or UI, or a question key no open question of the Spec carries. An entry address is `spec:#design.` or `spec:#ui.`, with a key matching -`^[a-z][A-Za-z0-9]*$`. It resolves against the named Spec's own section keys. +`^[a-z][A-Za-z0-9]*$`. It resolves against the named Spec's own section keys. An entry address may +also be `spec:#question.`, which resolves when one of the named Spec's open questions +carries that key in its marker; there `description` is an ordinary key. `unbacked` and `reverseOnly` group valid mentions by mentioning Spec and target Spec, combining Spec-level mentions and addresses, including missing entries. Self-addresses never enter these @@ -1425,12 +1453,24 @@ for (const spec of g.specs()) { const context = g.specContext(spec.id); if (context === undefined) continue; - const addressOf = (section, entry) => - (section === "design" || section === "ui") && - entry !== "description" && - /^[a-z][A-Za-z0-9]*$/u.test(entry ?? "") + const questions = context.sections?.intent?.openQuestions; + const questionKeyOf = (entry) => { + const index = /^openQuestions\[(\d+)\]\.question$/u.exec(entry ?? "")?.[1]; + const key = + index === undefined || !Array.isArray(questions) ? undefined : questions[Number(index)]?.key; + return typeof key === "string" && /^[a-z][A-Za-z0-9]*$/u.test(key) ? key : null; + }; + const addressOf = (section, entry) => { + if (section === "intent") { + const key = questionKeyOf(entry); + return key === null ? null : `${spec.id}#question.${key}`; + } + return (section === "design" || section === "ui") && + entry !== "description" && + /^[a-z][A-Za-z0-9]*$/u.test(entry ?? "") ? `${spec.id}#${section}.${entry}` : null; + }; const visit = (section, entry, key, text) => { const matchedIn = [ ...(key !== null && hits(key) ? ["key"] : []), @@ -1465,16 +1505,23 @@ for (const spec of g.specs()) { value.forEach((item, index) => walk(section, item, `${entry ?? ""}[${index}]`, null)); } else if (typeof value === "object" && value !== null) { // A key the author coins is content: any key of the open sections (`design`, `ui`) except - // `description`, and a `model.terms` term. A key the carrier fixes (`outcome`, `rules`, - // `given`) is structure and never matches. + // `description`, a `model.terms` term, and the key of an open question, which travels with + // the text of that question and is never a row of its own. A key the carrier fixes + // (`outcome`, `rules`, `given`) is structure and never matches. 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 === "intent" && /^openQuestions\[\d+\]\.key$/u.test(path)) continue; walk( section, item, - entry === null ? name : `${entry}.${name}`, - coined && !(entry === null && name === "description") ? name : null, + path, + coined && !(entry === null && name === "description") + ? name + : section === "intent" + ? questionKeyOf(path) + : null, ); } } @@ -1514,8 +1561,10 @@ The term matches when its tokens appear in the entry as one consecutive run, in | `retry worker` | `retry the worker` | no match, the tokens are not adjacent | Each row carries `address: string | null`. A Design or UI entry with a key matching -`^[a-z][A-Za-z0-9]*$` has address `spec:#
.`; `description`, off-grammar keys, -nested paths, and all other sections have `null`. +`^[a-z][A-Za-z0-9]*$` has address `spec:#
.`. The text of an open question whose +marker carries a key, at `openQuestions[].question`, has address `spec:#question.`, +and that key matches as the row's key; the key is not a row of its own. `description` in Design or +UI, off-grammar keys, other nested paths, and all other sections have `null`. Each row names the Spec, the `section`, the `entry` inside it, and the entry's `text`. `entry` is the key for a keyed entry (`envelopeSketch`, `terms.claim inheritance`), the field and zero-based index for a list entry (`rules[2]`), and `null` for the Spec's narrative, which is reported under @@ -1523,7 +1572,8 @@ the section name `narrative`. `matchedIn` says whether the key, the text, or bot A key matches whatever its value is. A non-string value renders as JSON and reports `matchedIn: ["key"]`; the front door shortens long text in default output just as it does for strings. Nested strings remain searchable at paths such as `retryPolicy.mode` or -`retryWorkers[0]`. Keys match only where the author coins them: the keys of `design` and `ui` and the terms of `model`. +`retryWorkers[0]`. Keys match only where the author coins them: the keys of `design` and `ui`, +the terms of `model`, and the keys of open questions. Field names the carrier fixes, such as `outcome` or `rules`, never match. Step text inside `gwt` and `gwt-vocabulary` fences is searched like any other entry. Titles and ids stay with concept search. Rows keep Spec id order and then the order the graph holds the entries; @@ -1557,3 +1607,187 @@ for (const spec of g.specs()) { } return { totals: { entries: rows.length, specs: new Set(rows.map((row) => row.spec)).size }, rows }; ``` + +## 25. Address resolution + +*When you need this: you hold entry addresses written outside the Specs, in a register, a test, +or a page, and want to know which still resolve.* + +The opening `const addresses` is the parameter. Replace it with the addresses you hold; an empty +list returns no rows. + +```js +const addresses = [ + "spec:consumers.design-review#ui.packPage", + "spec:consumers.design-review#ui.memberTable", + "spec:consumers.absent#design.anyKey", + "spec:consumers.design-review", +]; +const pattern = + /^(spec:[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)*)#(design|ui|question)\.([a-z][A-Za-z0-9]*)$/u; +const specIds = new Set(g.specs().map((spec) => spec.id)); +const holds = (id, section, key) => { + const sections = g.specContext(id)?.sections ?? {}; + if (section === "question") { + const questions = sections.intent?.openQuestions; + return ( + Array.isArray(questions) && + questions.some((entry) => typeof entry === "object" && entry !== null && entry.key === key) + ); + } + const entries = sections[section]; + return ( + key !== "description" && + typeof entries === "object" && + entries !== null && + Object.hasOwn(entries, key) + ); +}; +const rows = addresses.map((address) => { + const row = (resolves, id, section, key, reason) => ({ address, resolves, id, section, key, reason }); + const parsed = typeof address === "string" ? pattern.exec(address) : null; + if (parsed === null) return row(false, null, null, null, "malformed"); + const [, id, section, key] = parsed; + if (!specIds.has(id)) return row(false, null, null, null, "spec"); + if (!holds(id, section, key)) return row(false, null, null, null, "entry"); + return row(true, id, section, key, null); +}); +const count = (reason) => rows.filter((row) => row.reason === reason).length; + +return { + totals: { + addresses: rows.length, + resolved: rows.filter((row) => row.resolves).length, + malformed: count("malformed"), + spec: count("spec"), + entry: count("entry"), + }, + rows, +}; +``` + +Each row answers one input, in input order, repeats included. A row that resolves carries the +Spec `id`, the `section` (`design`, `ui`, or `question`), the `key`, and `reason: null`. A row that +does not resolve carries `id`, `section`, and `key` as null and one `reason`: `"malformed"` for an +input that is not an entry address, a bare Spec id, a `pack:` id, and a value that is not a string +among them; `"spec"` for an address whose Spec is absent; and `"entry"` for an address whose Spec +holds no such entry. A Design or UI key resolves as an own key of that section other than +`description`. A question key resolves when one of the Spec's open questions carries it in its +marker, `[blocking #aggregateReach]`. These are the grammar and the rule `sdp validate` applies to +an address written in a Spec's prose, under `spec:decisions.checked-mentions`; the mention audit +(recipe 22) reads addresses inside the Specs, and this recipe reads the ones written anywhere +else. `totals` counts the inputs, the resolving rows, and each reason. The law is +`spec:consumers.agent-surface.address-and-cycle-recipes`. + +## 26. Dependency cycles + +*When you need this: you want every place where Specs rest on each other, or on themselves, +through `dependsOn`.* + +```js +const ids = g.specs().map((spec) => spec.id); +const known = new Set(ids); +const targets = new Map(ids.map((id) => [id, []])); +for (const edge of graph.edges) { + if (edge.type === "dependsOn" && edge.claim === "declared" && known.has(edge.from) && known.has(edge.to)) { + targets.get(edge.from).push(edge.to); + } +} +for (const list of targets.values()) list.sort(); + +const order = new Map(); +const low = new Map(); +const stack = []; +const onStack = new Set(); +const sets = []; +const visit = (id) => { + order.set(id, order.size); + low.set(id, order.get(id)); + stack.push(id); + onStack.add(id); +}; +const connect = (root) => { + visit(root); + const frames = [{ id: root, at: 0 }]; + while (frames.length > 0) { + const frame = frames[frames.length - 1]; + const list = targets.get(frame.id); + if (frame.at < list.length) { + const next = list[frame.at]; + frame.at += 1; + if (!order.has(next)) { + visit(next); + frames.push({ id: next, at: 0 }); + } else if (onStack.has(next)) { + low.set(frame.id, Math.min(low.get(frame.id), order.get(next))); + } + continue; + } + frames.pop(); + if (low.get(frame.id) === order.get(frame.id)) { + const members = []; + let member; + do { + member = stack.pop(); + onStack.delete(member); + members.push(member); + } while (member !== frame.id); + sets.push(members.sort()); + } + const parent = frames[frames.length - 1]; + if (parent !== undefined) low.set(parent.id, Math.min(low.get(parent.id), low.get(frame.id))); + } +}; +for (const id of ids) if (!order.has(id)) connect(id); + +const cycleThrough = (members) => { + const inside = new Set(members); + const start = members[0]; + const parent = new Map(); + const queue = [start]; + for (let at = 0; at < queue.length; at += 1) { + const current = queue[at]; + for (const next of targets.get(current)) { + if (!inside.has(next)) continue; + if (next === start) { + const path = [start]; + for (let step = current; step !== start; step = parent.get(step)) path.splice(1, 0, step); + return [...path, start]; + } + if (!parent.has(next)) { + parent.set(next, current); + queue.push(next); + } + } + } + return []; +}; + +const cycles = sets + .filter((members) => members.length > 1 || targets.get(members[0]).includes(members[0])) + .sort((left, right) => (left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0)) + .map((members) => ({ members, cycle: cycleThrough(members) })); + +return { + totals: { + sets: cycles.filter((entry) => entry.members.length > 1).length, + selfDependent: cycles.filter((entry) => entry.members.length === 1).length, + specsInCycles: cycles.reduce((sum, entry) => sum + entry.members.length, 0), + }, + cycles, +}; +``` + +The body takes no parameter and reads the whole graph. The walk keeps its own frame stack, so a +dependency chain of any length runs without recursing. Each entry of `cycles` is a set of two or +more Specs in which each Spec reaches every other through declared `dependsOn` edges, or one Spec +that declares `dependsOn` on itself and belongs to no such set. A Spec that reaches a set without +being reached back belongs to none, and an edge whose target is not a Spec in the graph is +ignored. `members` lists the set sorted by id. `cycle` is one closed path through the set: the +shortest that starts and ends at the first member, found by a breadth-first walk over targets in +id order, so a Spec that rests only on itself reads `[id, id]`. Entries keep the order of their +first members. `totals.sets` counts the sets of two or more, `totals.selfDependent` the Specs that +rest only on themselves, and `totals.specsInCycles` every member of either. The recipe reports and +never refuses: a cycle is data about the authored dependencies, and the readiness floor reads each +`dependsOn` target's stated rung without walking a chain. The law is +`spec:consumers.agent-surface.address-and-cycle-recipes`. diff --git a/docs/concept/06-consumers-and-projections.md b/docs/concept/06-consumers-and-projections.md index 900db7cc..ce2605bf 100644 --- a/docs/concept/06-consumers-and-projections.md +++ b/docs/concept/06-consumers-and-projections.md @@ -145,10 +145,12 @@ Why patching dissolves: The flagship human view is the Design Review's relationship slice: a derived, regenerable human-readable projection — **fully derived** and reproducible (delete and rebuild identically). Per spec it shows: header (title, `kind`, `altitude`, `readiness`, and any stated-vs-derived divergence); intent and behaviour (rules/examples); relations; bindings (implementing code, tests, with source links, derived from anchors); verification status (does a linked, enabled verifier *exist* — the `has-verifier` delivery fact, not run results); an impact list; and `claim` cues (declared vs anchored vs inferred shown distinguishably, P9). The census, Mermaid, and Gherkin-shaped read projections are co-equal derived views with narrower jobs; none re-specifies the Design Review. -Owned prose is already available through the graph and Reader: `narrative` appears in a spec summary/context and concept search, while section descriptions remain in `SpecContext.sections` and are searchable. Design Review now renders `Spec.narrative` and the seven approved section descriptions from those Reader/graph values (schema `0.6.0`), with no source reparse and stable omission when prose is absent. +Owned prose is already available through the graph and Reader: `narrative` appears in a spec summary/context and concept search, while section descriptions remain in `SpecContext.sections` and are searchable. Design Review now renders `Spec.narrative` and the seven approved section descriptions from those Reader/graph values (schema `0.7.0`), with no source reparse and stable omission when prose is absent. Schema `0.6.0` changes accepted ids by reserving the `#` sub-part for an entry address and changes ordering semantics by preserving authored key order in `design`, `ui`, and `model.terms`, with a `design` or `ui` section's `description` first. Field shapes are unchanged. +Schema `0.7.0` adds a Pack node's ordered `members` list and an open question's optional `key`, and admits `question` as an entry-address section. Node and edge order are unchanged. + **Form is a Representation — settled for the MVP: generated Markdown.** An index plus one page per `Spec` and per `Pack` under `generated/design-review/` (`sdp view`), rewritten wholesale each run so no stale page survives; byte-exact regeneration is the same determinism discipline as the graph. The dev-mode and CI surfaces are the *same* generated artifact (no drift-prone "dev view"). The rich interactive **Spec Studio**, and HTML-over-Markdown as a product thesis, are aspirational (§8). --- diff --git a/docs/lineage/annotation-scout-2026-10-04.md b/docs/lineage/annotation-scout-2026-10-04.md new file mode 100644 index 00000000..3ed06d4f --- /dev/null +++ b/docs/lineage/annotation-scout-2026-10-04.md @@ -0,0 +1,220 @@ +> **Research report, 2026-10-04.** A read-only scout of the first generation's annotation model (`libar-dev/architect` at `854f4a7`), of this repository's two decisions that refuse it, and of the original design in this folder, written for the owner's decision on annotations in code. It is dated evidence, not current truth: on any disagreement with `specs/`, the Specs win until a decision supersedes them. Paths to gen 1 link to its public repository at the commit read; references to the adopter's working notes are not published. + +# Annotations in code: gen 1, the original design and today + +## 1. Gen 1’s annotation model + +**Gen 1 formally defines one graph authored through tagged Gherkin and TypeScript; its current implementation supports a narrower—and partly different—tag set than the formal specification.** + +The authoritative starting points are [formal-spec/README.md](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/formal-spec/README.md), [03-tag-system.md](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/formal-spec/03-tag-system.md) (**F03** below), [04-tag-registry.md](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/formal-spec/04-tag-registry.md) (**F04**), and [10-pattern-graph.md](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/formal-spec/10-pattern-graph.md) (**F10**). Sections in these documents are named rather than numbered; the references below use those names. + +[00, “Five Core Concepts”](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/formal-spec/00-overview.md#L39) defines a pattern as a named architectural unit: feature, service, component, decision, or capability. Gherkin carries behavior, rules, and delivery state; TypeScript carries runtime structure and relationships. F10, “Overview” and “Build Pipeline,” makes the immutable `PatternGraph` the common read model for documentation, queries, agents, and UI. + +[01, “Conformance Levels”](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/formal-spec/01-conformance.md#L16) distinguishes minimal metadata, complete specifications, and enforced lifecycle/projections. Thus “present in the registry,” “required for this artifact/tier,” and “checked by the extractor” are different claims. + +**Inventory notation.** Every suffix below means `@architect-`. `V`: formal TypeScript `@architect-x value`, Gherkin `@architect-x:value`; `CSV`: corresponding comma-separated values; `Q`: quoted TypeScript value, colon-delimited Gherkin token; `F`: bare flag. Current TypeScript accepts colon or whitespace separators; skills specifically teach colons for `role`/`bounded-context` and whitespace for `pattern`/`uses`/`implements`. Gherkin requires colons and cannot contain whitespace inside a tag. + +Counts are **TypeScript/Gherkin authored occurrences** in Architect’s configured, deduplicated source inputs at `854f4a7`: opted-in JSDoc blocks and actual Feature-header tags, excluding documentation examples, scenario payloads, and fixtures outside those inputs. `shape` additionally counts declaration-level markers. The live graph contained **349 patterns: 227 TypeScript and 122 Gherkin**; these counts measure authoring, not semantic correctness. + +Checks below: **T** = TypeScript comment parser plus `DocDirective`/pattern schemas; **G** = Gherkin registry dispatch plus extraction diagnostics/schemas; **R** = relationship resolution/dangling validation; **P** = process/tier/hierarchy guard. None of the JSDoc values is checked by `tsc`. + +| Tag | Syntax | Formal definition → current graph meaning | Allowed values and actual checker | Uses TS/G | +|---|---|---|---|---:| +| `@architect` | F | F03 “Gate Tag”; extraction opt-in, not a node | Exact gate; T/G | 227/122 | +| `pattern` | V | F04 Group 1; pattern identity → node identity/name | Formally unique PascalCase; T/G extract; duplicate-feature guard checks collisions before merging | 227/122 | +| `status` | V | F04 Group 1; lifecycle attribute and status views | `candidate`, `roadmap`, `active`, `completed`, `deferred`; T/G enums, P transitions | 227/122 | +| `maturity` | V | F04 Group 1 and “Status → Maturity Defaults”; authored override, otherwise derived | `idea`, `plan`, `design`, `executable`; **not registered in graph extraction**; P separately reads explicit idea marker | 0/3 | +| `role` | V; commonly colon in TS | F04 Group 2; architectural classification attribute | `projection`, `service`, `decider`, `read-model`, `codec`, `contract`, `barrel`, `utility`; configured registry, T/G | 227/31 | +| `bounded-context` | V; commonly colon in TS | F04 Group 2; context attribute/grouping | Free string; T/G; no closed context registry | 227/12 | +| `arch-layer` | V | F04 Group 2; formally an architecture attribute | `application`, `domain`, `infrastructure`; **absent from live registry**, despite formal requirement | 0/0 | +| `product-area` | V | F04 Group 2; organizational attribute and documentation grouping | Dogfood: `Annotation`, `Configuration`, `Generation`, `Validation`, `DataAPI`, `CoreTypes`, `Process`, `Projection`; G checks configured list; TS transport does not enforce that list equivalently | 16/112 | +| `team` | V | F04 Group 3 note; canonical feature ownership metadata | Project string; **not registered**, therefore no standard graph extraction | 0/0 | +| `uses` | CSV | F04 Group 4; A depends on B; reverse views derived | Pattern-name list; T/G parse; R resolves/detects dangling targets; P uses dependency status | 144/20 | +| `implements` | CSV | F04 Group 4; realization edge, reversed as `implementedBy` | Pattern-name list; T/G/R; used both code→spec and executable-feature→production-pattern | 14/75 | +| `extends` | V | F04 Group 4; specialization/generalization edge | One pattern name; T/G/R | 0/0 | +| `see-also` | CSV | F04 Group 4; informational edge without dependency implication | Pattern-name list; T/G/R | 0/15 | +| `enforces-decision` | CSV | F04 Group 4 calls it “derived” and excludes it from the authored set; **code registers an authored edge** to decisions, with reverse `enforcedBy` | Decision-pattern names; T/G and decision resolution | 7/2 | +| `level` | V | F04 Group 7; hierarchy attribute | `epic`, `phase`, `task`, `slice`; T/G enums, P | 0/2 | +| `parent` | V | F04 Group 7; parent edge | Pattern name; P requires a strictly higher hierarchy level; epic/slice parent exemption | 1/7 | +| `target` | V | F04 Group 9; stub’s intended production path | Path string; T/G transport; formal MUST for stubs | 0/0 | +| `unlock-reason` | Q | F04 Group 11; explanation suppressing advisory reopen warnings | G/P require meaningful, non-placeholder text of at least ten characters; TS initially transports text | 0/41 | +| `adr` | V | F04 Group 6; ADR identifier attribute | Number-like identifier padded by transform; G; TS parser does not transport ADR fields into its directive | 0/15 | +| `adr-status` | V | F04 Group 6; decision lifecycle attribute | `proposed`, `accepted`, `deprecated`, `superseded`; G; formal reserves supersession for post-bootstrap append-only use | 0/15 | +| `adr-category` | V | F04 Group 6; decision classification | `architecture`, `process`, `testing`, `documentation`; G | 0/15 | +| `adr-theme` | V | F04 Group 6; decision theme | Formal examples: `performance`, `security`, `scalability`; live enum: `persistence`, `isolation`, `commands`, `projections`, `coordination`, `taxonomy`, `testing`; G | 0/15 | +| `adr-layer` | V | F04 summary/F03 ADR requirements; evolutionary decision layer, **not** architecture layer | `foundation`, `infrastructure`, `refinement`; G | 0/15 | +| `adr-supersedes` | V | F04 Group 6; decision supersession reference | ADR identifier, padded; G; unavailable through TS directive transport | 0/0 | +| `adr-superseded-by` | V | F04 Group 6; inverse supersession reference | Same | 0/0 | +| `usecase` | Q | Still appears in F04 summary; formerly usage guidance | **Retired from registry, schemas, and projection**; surviving occurrences confer no structured field | 4/0 | +| `overview` | F | F04 summary; aggregation selector | Registered selector targeting `OVERVIEW.md`; TS document extraction, not a relationship | 0/0 | +| `decision` | F | F04 summary; documentation aggregation | Registered selector targeting `DECISIONS.md`; **different from `enforces-decision`** | 1/0 | +| `intro` | F | F04 summary; package-introduction selector | Registered template selector; no target document; TS document extraction | 0/0 | +| `executable-specs` | CSV | Additional live tag; forward link used by design-spec deletion/value-transfer checks | Feature paths; T/G transport, session/deletion checks | 0/2 | +| `title` | Q | Additional live registry tag; intended display attribute | String; registered but omitted from final TS/Gherkin pattern construction | 0/0 | +| `shape` | F, optional group text | Additional live tag; selects exported AST declarations for API documentation | Exported interface/type/enum/const/function; AST shape extractor; no equivalent Gherkin output | 238/0 | +| `depends-on` | CSV | F04 Group 4 explicitly retires authored form; live `dependsOn` view is populated from `uses` | Unregistered; no independent authored edge | 0/0 | +| `enables` | CSV | F04 Group 4 explicitly retires authored form; live reverse dependency view derives from `uses` | Unregistered; surviving example is ignored | 0/1 | +| `used-by` | CSV | F04 Group 4 retires authored form; reverse of `uses` | Derived, not authored | 0/0 | + +F04’s other explicitly removed names—`phase`, `effort`, `priority`, `release`, `quarter`, `risk`, `business-value`, `user-role`, `constraints`, sequence `orchestrator`/`step`/`module`/`error`, four `discovered-*` tags, `include`, `since`, `shapes`, `workflow`, `api-ref`, `depends-on-external`, `parent-external`—each have **0/0** occurrences in the counted corpus. They have no standard live extraction semantics. Historical `completed` and `effort-actual` also have 0/0. Unregistered bare `cli`, `lint`, and `validation` survive in 9, 10, and 6 TypeScript identity blocks respectively; they are not canonical roles. + +[05, §7 “Scenarios”](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/formal-spec/05-feature-spec-format.md#L288) additionally defines non-Architect scenario flags: `@acceptance-criteria` plus one of `@happy-path`, `@validation`, `@edge-case`. The configured opted-in features contain respectively **301, 348, 86, 28** such occurrences. These classify scenarios; they do not create independent pattern nodes. + +**Implementation and checks.** The current registry contains **24 metadata tags + three aggregation tags + the gate**. Its source is [registry-builder.ts](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/packages/architect-core/src/taxonomy/registry-builder.ts); schemas and registry merging are in [tag-registry.ts](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/packages/architect-core/src/validation-schemas/tag-registry.ts), with workspace roles/source globs in [self-hosting.ts](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/packages/architect-core/src/config/self-hosting.ts) and product-area configuration in [architect.config.ts](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/architect.config.ts). The generated taxonomy digest deliberately hides `title`, `target`, and `unlock-reason`; its smaller count is not the full registry count. + +[ast-parser.ts](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/packages/architect-core/src/scanner/ast-parser.ts) uses the TypeScript ESTree parser to find comments, then tag-specific text extraction and explicit directive-field transport. [gherkin-ast-parser.ts](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/packages/architect-core/src/scanner/gherkin-ast-parser.ts) parses Cucumber syntax and dispatches Feature tags through the registry. [relationship-resolver.ts](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/packages/architect-core/src/generators/pipeline/relationship-resolver.ts) constructs forward/reverse indexes. Unknown tags are generally ignored, not automatically rejected. + +Additional formal/code discrepancies: F03 requires status, while TS extraction defaults missing status to `roadmap`; Gherkin requires the identifying header and diagnoses/skips missing metadata. F04 says explicit maturity always overrides its default, while the graph derives maturity from status. F10 retains completion-date and timeline-related descriptions that the implementation retired. TS enum extraction also uses a value-matching regex rather than the Gherkin parser’s exact-membership path. + +**Adding a tag.** Formally, F03 “Tag Taxonomy” and 01 “Extension Points” permit project tags without changing standard semantics. In code, registering a tag supplies syntax/metadata, but does **not** guarantee graph support: add its schema field and parser/extractor transport, then any relation resolution, checks, and projection consumers. Roles and product areas have configuration hooks; arbitrary graph fields are not automatically admitted by those hooks. + +**How agents were taught.** [architect-base](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/.agents/skills/architect-base/SKILL.md) teaches the taxonomy, lifecycle, and durable value transfer; [architect-graph-handle](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/.agents/skills/architect-graph-handle/SKILL.md) requires reading actual graph results. [architect-sessions](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/.agents/skills/architect-sessions/SKILL.md) and [architect-refactor-session](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/.agents/skills/architect-refactor-session/SKILL.md) require preserving executable rules and refreshing relationships. Their [ownership guidance](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/.agents/skills/architect-base/references/annotation-ownership.md) distinguishes feature-owned behavior from code-owned structural patterns, prohibits duplicate identities, and makes production annotations curated rather than a completion quota. The refactor guidance still prescribes `usecase`, despite its removal. + +## 2. How Gen 1 generates documentation + +**Documentation is a projection of the same assembled graph, combining annotation metadata with source prose, Gherkin rules, and extracted TypeScript declarations.** + +F10 “Build Pipeline” specifies configuration → TS/Gherkin extraction → reconciliation → hierarchy/schema validation → indexes → immutable graph. [12, “Architecture” and “Invariants”](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/formal-spec/12-live-documentation-api.md#L50) explicitly places structured document output before rendering: markdown and interactive UI are consumers of that representation. + +The current [documentation bundle](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/packages/architect-projection/src/projections/documentation-composition/documentation-bundle.ts) composes graph projections into typed fragments; rendering and [output routing](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/packages/architect-projection/src/projections/documentation-composition/documentation-type-registry.output-routing.ts) produce files. [generate-docs.ts](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/packages/architect-cli/src/cli/generate-docs.ts) is the CLI composition root. + +| Outputs generated in this inspection | Principal graph inputs | +|---|---| +| `PATTERNS`, `ARCHITECTURE`, architecture child views, `DESIGN-REVIEW` | Identity, role, context, dependencies, status, source location | +| `API-REFERENCE` and package pages | `shape` markers, exported AST declarations, declaration JSDoc | +| `DECISIONS` and individual ADR/PDR pages | ADR tags; feature Context/Decision/Consequences and Rules | +| `BUSINESS-RULES` and package pages | Gherkin Rules, invariants, rationale, scenarios, realization links | +| `ROADMAP`, `CURRENT-WORK`, `CHANGELOG` | Lifecycle views; current changelog is completed patterns, not release history | +| `REQUIREMENTS-EXECUTABLE`, `REQUIREMENTS-SPECS`, `TRACEABILITY` | Source type, product area, status, identity, `implements` | +| `VALIDATION-RULES`, `TAXONOMY`, `INDEX` | Validation-rule content, live registry/configuration, generated-document manifest | + +I ran CLI help/list and explicitly selected the 15 whole-file generators, writing only to scratch/annotation-scout-docs-854f4a7 (in the adopter unit's working notes, not published). The run generated **46 files from 349 patterns**. I avoided `--all`: it also updates embedded generated regions inside the repository. + +A concrete example is TRACEABILITY.md (in the adopter unit's working notes, not published). [shape-extractor.ts](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/packages/architect-core/src/extractor/shape-extractor.ts#L1) declares `ShapeExtractor`, status `active`, role `service`, context `extractor`. [shape-extraction-types.feature](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/packages/architect-core/tests/features/extractor/shape-extraction-types.feature#L1) declares `ShapeExtraction`, `implements:ShapeExtractor`, status `completed`, area `Annotation`. The generated page joins the production pattern to that executable feature and its source path. Their different statuses remain distinct. + +The example’s `@architect-enables:DocGenerationProofOfConcept` contributes **nothing**: it is unregistered. Its Rules and scenarios do contribute documentation. Similarly, the generated ADR-013 page (in the adopter unit's working notes, not published) projects decision prose and consequences, not merely its header tags. + +## 3. What drifted in Gen 1’s history + +**History shows deliberate vocabulary contraction, residual documentation/skill drift, and specific extraction defects; it does not establish that two source formats caused the contraction.** + +| Date / commit | Evidence, including commit wording | What changed | +|---|---|---| +| 2026-05-16 `ee58aac` | Standalone extraction baseline | Runtime registry already had 23 metadata tags plus three aggregation tags; the formal registry described roughly 50. | +| 2026-05-17 `1a1eb0b` | “Review and fix false claims in formal specs” | Formal taxonomy was narrowed to approximately 26. This is not evidence of a shipped runtime registry shrinking directly from 50 to 26 in this repository. | +| 2026-05-17 `691da3c` | “refactor(taxonomy): retire @architect-usecase”; “Free text was the wrong substrate.” | Removed registry entry, extraction, schemas, and rendered usage guidance. The message says trigger conditions duplicated Gherkin Scenario titles’ actor/goal/outcome structure. | +| Subsequent `bf6cb87`, `784880f` | Shape extraction and decision-resolution additions | Added useful vocabulary: `shape` and authored `enforces-decision`. Contraction was not a blanket rejection of annotations. | +| 2026-06-01 `a84aa4e` | Registry/extractor cleanup | Registered already-parsed `executable-specs`; removed remaining orphaned `usecase` parser handling. | +| 2026-06-05 `51d00fc` | “Update decision records and complete first part of refactoring” | Removed temporal machinery: quarter, numeric phase, release, completion date, and associated views/fields. | +| 2026-06-05 `1654ebe`, `5b14f64` | “refactor(taxonomy): retire metadata residue”; “refactor: finalize taxonomy retirement cleanup” | Removed effort/actual effort/risk/priority/since/user-role/business-value residue across schemas and consumers. | + +[ADR-013](https://github.com/libar-dev/architect/blob/854f4a75936dda043d3683a977be0eeee4ade655/architect/decisions/adr-013-retire-unpopulated-temporal-axes.feature) gives the recorded rationale: these dimensions carried “no (or near zero) populated data” and were inherited monorepo machinery. It states that `release` had never been a registered taxonomy tag; its data path was an unused table column. The decision also removes temporal/historical state from the live graph in favor of git history. + +F04 Groups 3, 5, 8, 10, 12 and its summary document the wider formal removals: planning/business/discovery/sequence tags and redundant authored reverse relationships. The README changelog also records removal of external-dependency/parent and sequence/extraction-marker vocabulary. That documentary history must be distinguished from executable parser removals. + +The extraction defects are separately identifiable: + +| Commit | Commit’s own diagnosis | Defect and repair | +|---|---|---| +| `c398088`, 2026-05-29 | “featureIndex map silently last-write-wins” | Two Gherkin files shared identity; merging dropped one file’s rules. Added duplicate detection on raw features before collapse and split the identities. | +| `bda27f4`, 2026-05-29 | Graph “reported fiction patterns” | Throwaway CLI fixtures carried real tags and entered the production scan; removed them. | +| `85bbc4c`, 2026-06-13 | “fix 2 tooling gaps”; “silent-failure-to-zero” | Added missing package-local feature globs and a Gherkin space-form tag lint; corrected missing gates and non-comma dependency syntax. | + +Current mismatches in item 1—retired tags still taught by skills, formally canonical fields absent from extraction, and differing TS/Gherkin transport—are observable drift. They are not evidence that an annotation graph requires separate, divergent read models. + +## 4. Gen 2’s refusals and the original typed concept + +**Today’s Gen 2 restricts source authoring to a small anchor constant; the owner-selected v0 design explicitly supported typed markers, JSDoc, richer architectural nodes, and three distinct edge provenances.** + +The operative text of [jsdoc-graph-extraction-refused](../../specs/decisions/jsdoc-graph-extraction-refused.sdp.md) is: + +> “Ordinary JSDoc and local commentary never author graph content — no nodes, no relations, no component membership, no delivery facts, no Spec intent.” +> “The only write path from source into the graph is the statically reified anchor constant under `spec:model.anchors`, and it is identity only.” + +Its alternative explicitly includes structured tags in the refusal: + +> “Extracting identity tags from JSDoc (the v0 JSDoc marker style) stays refused with the decorator form — both are unextracted representations, and the anchor constant is the single binding syntax.” + +The operative parts of [architectural-significance-rides-primitives](../../specs/decisions/architectural-significance-rides-primitives.sdp.md) say: + +> “no ‘pattern’ term or kind is ratified.” +> “code linkage rides the `satisfies` → `decidedBy` join” +> “New `codeAnchor`s minted under this ruling satisfy only Specs the unit already realizes; anchors are never pointed at decision Specs or unfinished Specs to manufacture coverage.” + +That record expressly gives up “CodeNode-grain roles” and machine-checked forbidden dependencies. It allows architectural significance on existing Spec kinds according to the truth being stated, existing relations, component membership, and `uses`. + +Current [anchors.ts](../../src/model/anchors.ts) exposes `id`, optional `label`, **one required `satisfies`**, optional `component`, and optional `uses`. [Source extraction](../../src/extract/anchors.ts) admits statically reifiable top-level constants using trusted builders, not arbitrary executable metadata. A resolving `CodeNode → satisfies → Spec` confers structural `implemented` under [delivery-facts.ts](../../src/graph/delivery-facts.ts); it does not inspect whether the implementation fulfills the claim. + +For the original design, **C01–C05** below mean [01 Core primitives](../../docs/lineage/v0-design/01-core-primitives.md), [02 System architecture](../../docs/lineage/v0-design/02-system-architecture.md), [03 Graph metamodel](../../docs/lineage/v0-design/03-graph-metamodel.md), [04 Authoring surfaces](../../docs/lineage/v0-design/04-authoring-surfaces.md), and [05 Runtime anchors](../../docs/lineage/v0-design/05-runtime-anchors.md). C01–C03 were read in full. + +**The refined concept’s model.** C01 §§1.1–1.4 defines one persistent Spec with kind, abstraction, readiness, and optional typed facets. Types constrain shape; validators determine completeness. §2.5 permits design components, ports, dependencies, and decisions before runtime bindings (§2.6) or code bindings (§2.7). §§6–8 retain the Spec’s identity through enrichment and refinement. Thus an unrealized design is already legitimate graph content. + +**Its architectural graph.** C03 §§2.5–2.7 distinguishes declared Components, Ports, and source-bound Implementations; a Component can exist in architecture declarations before code. Component fields include `layer` and `bounded_context`. §§3–3.1 defines `contains`, `dependsOn`, `satisfiedBy`, `implements`, and other architectural edges. Here `implements` means **Implementation→Port**; code realization of a Spec is represented by **Spec→satisfiedBy→Implementation**, not Gen 1’s overloaded `implements` spelling. + +**Its provenance.** C03 §§4.1–4.3 explicitly separates **declared** edges from Spec/architecture files, **annotation** edges from code markers, and **inferred** edges from extractors, with location/marker or extractor/reasoning/confidence. Inferred findings are advisory and cannot alone become validator errors. C02 §§1, 4.1, 7 puts all extractors through one canonical graph and makes every projection a function of that graph. Three edge sources do not mean three read models. + +**Its carriers.** C04 §2 says, “Three marker styles, all equally supported by the extractor”: decorator object literals, JSDoc tags, and typed marker helpers. §2.3 writes a function as `markImplementation({ id, kind, component, satisfies: [...] })(functionBody)`; the wrapper is runtime identity and statically recognizable. §2.4 makes `satisfies?: SpecId[]` optional and admits component/capability/port/event links, visibility, and tags. It excludes readiness, intent, behavior, and verification from implementation markers. + +**Its compiler guarantees.** C01 §7.2 and C03 §6.4 combine nominal ID brands with a **generated union of known IDs**; typed helpers constrain namespaces, enum values, object shape, and referenced IDs when that union is generated and used. C04 §1.1 preserves literals through generic constructors. C02 §11 addresses stale unions by regenerating before typechecking. Neither branding alone nor JSDoc gives compile-time referential integrity; graph validation still checks resolution, uniqueness, completeness, and relationships. + +**The runtime examples are concrete, not merely comment decoration.** C05 §2.1 says `defineRoute` both wraps route registration and “Is recognisable by the extractor”; it yields a Route node, structural membership/realization, and inspected invocation links. C05 §4.2’s `defineRegistrations({...} as const)` declares IDs, lifetimes, `dependsOn`, and `satisfies`: dependencies produce runtime `requires`; satisfaction produces Spec→implementation/layer `satisfiedBy`. + +**`ed5f6ad^`.** Its parent is `64cf1c3`. Reading `git show ed5f6ad^:docs/concept/01-core-primitives.md` and sibling chapters finds the original model above. The present C01–C05 bodies match those parent versions after removing the later lineage header. Their later restoration date does not make the underlying concept later than `5d9681f`; they are nevertheless the design basis selected by the owner for this report. + +**`5d9681f`, 2026-06-06.** The relevant paths are `docs/concept/02-core-model.md`, `03-the-one-graph.md`, and `04-authoring-and-binding.md`. Chapter 04 §2 still presents interchangeable decorator, JSDoc, and `markImplementation({ id, satisfies: [...], component })` constant forms, producing **annotation-provenance** bindings/structure while forbidding Spec intent in markers. Chapter 02 §§1–3 provides typed Spec shapes; §5 makes the generated ID union **optional**, with referential integrity enforced by validation. It therefore describes type-safe TS authoring, but not compiler proof of every ID reference in every carrier. Compared with Gen 1, it separates Spec truth from code identity; compared with today, it permits multiple marker representations and plural satisfaction, and does not reduce all source bindings to today’s required single-Spec constant. + +## Progression through the concept + +**The history moves from a broad typed metamodel to a smaller implemented anchor contract; the owner now selects the preserved broad v0 model as the basis for reconsideration.** + +- **2026-06-06 06:46, `3c0a8f6`:** “Intialize with first synthesis.” Original C01 §§1, 2, 7, C02 §§1, 4, and C03 §§2–5 already contain typed Specs, architectural nodes, three marker forms, and declared/annotation/inferred provenance. +- **2026-06-06 09:12, `64cf1c3` = `ed5f6ad^`:** “Record ephemeral prototype synthesis analysis.” The original annotation chapters remain unchanged; this is the parent snapshot now preserved as v0. +- **2026-06-06 10:36, `ed5f6ad`:** “Update inital concept to the very first initial version.” Narrows the MVP, defers deep runtime/Gherkin/harness work, makes generated-ID typing optional, and treats verification structurally; the three generic marker representations remain. +- **2026-06-06 12:22, `5d9681f`:** “Review and update both stories and concept docs.” Retains chapter 04 §2’s interchangeable markers and annotation provenance; chapter 02 §5 leaves reference existence primarily to graph validation. +- **2026-06-06 13:27, `9bc360a`:** “Polish fundamental concepts.” Architectural significance governs detail; established shapes are referenced rather than repeatedly restated. Marker grammar is not replaced. +- **2026-06-07, `7140c3d`, `0ca7ed5`:** Terminology shifts toward anchor/anchored and altitude; readiness and delivery facts separate. The distinction between authored truth and source binding persists. +- **2026-06-10, `4bc5645`, `4638089`, `27c0eed`:** Source/test bindings become assertion-only; the implemented `codeAnchor` path settles on statically extracted constants and one required `satisfies`. Decorator/JSDoc concepts remain unimplemented. +- **2026-07-18, `0a06882`:** Markdown becomes the default Spec authoring surface, with per-ID canonicity; code anchors remain TypeScript. +- **2026-08-13, `778ce2a`:** Restores original C01–C05 under `docs/lineage/v0-design/`; C03 §4’s three-source provenance is preserved as documentary design, not restored runtime functionality. +- **2026-08-14–15, `29b11c0`, `7e22c63`, `14cc559`:** Adds structural `component`/`uses` and self-binding without additional delivery facts. +- **2026-08-21, `723bf95`, `1ea9741`:** Ratifies the no-pattern-vocabulary and no-JSDoc rulings. +- **2026-09-06, `57a67d2`:** Broadens architectural responsibility across existing Spec kinds and uses exported-public **or** cross-component significance; the closed anchor fields and absence of code-grain roles remain. + +## 5. The smallest set on Gen 2’s graph + +**The smallest defensible set preserves identity, role, context, realization, dependency, lifecycle, and an informational design reference; `depends-on` and `enables` need no separate source tags.** + +The Studio claim map (in the adopter unit's working notes, not published) is an **81-row reading of Protocol code at `e945811`**, held in adopter calibration context: 5 realized, 25 partly, 25 not built, 25 departed, 1 not placed. Its draft twelve-member Studio Pack was unanchored. It is not a census of implemented Studio code in `libar-platform`. At the inspected current state, the ten `spec:consumers.spec-studio*` Specs had no delivery facts; the adopter graph had 243 Specs, 40 CodeNodes, no structural `memberOf`/`uses` edges, and no Studio Specs. + +The proposed carrier is the **v0 typed marker model**, with a JSDoc representation feeding the same normalized schema. Typed markers receive compiler checks; JSDoc receives equivalent extractor/validator checks. Each code identity has one canonical authored representation. This restores representation choice without creating another graph. + +| Gen-1 meaning retained | Gen-1 formal definition | v0 node, edge, and provenance | Mapping onto today’s Gen 2; required departure | Carrier | +|---|---|---|---|---| +| Gate + `pattern`: named architectural unit | F03 “Gate Tag”; F04 Group 1; F10 “Identity Fields” | C01 §§1.1, 7; C03 §§2.1, 2.7, 5 distinguish desired Spec identity and source Implementation identity; C02 §4.1 extracts both | Keep Spec/CodeNode IDs. A shared architectural pattern can be an explicitly named Spec of an existing suitable kind; no new `Pattern` node kind is necessary. Code identity must be possible without mandatory realization. | Typed marker or gated JSDoc for code; canonical Spec surface for desired truth | +| `role` | F04 Group 2; F10 “Architecture” | C03 §2 supplies node metadata, but **does not define Gen 1’s role enum**; C01 §1.1 permits tags, not equivalent role semantics; C02 §4.1 provides extraction placement | Add a closed, configurable role field on CodeNode/marker. This is a small metamodel extension, not something already supplied by `label`. | Both; shared enum/schema | +| `bounded-context` | F04 Group 2; F10 “Architecture” | C01 §2.5 references design Components; C03 §2.5 defines Component `bounded_context`; §3.1 `contains`; §§4.1–4.2 distinguish declared grouping from annotated membership; C02 §4.1 | Preserve `component`→`memberOf`; add declared Component context metadata. Context and component need not be synonymous. Today’s id-family grouping does not encode this attribute. | Context on component declaration; component membership in either marker carrier | +| `implements`: code realizes a Spec | F04 Group 4; F10 “Relationships”; 08 “N:1 Pattern Mapping” | C01 §2.7 and §4; C03 §3.1 `satisfiedBy`, annotation source §4.2; C02 §4.1 | Existing `satisfies` is the corresponding reversed edge. Restore v0 optional/plural binding where needed. Do not copy v0 `implements`, which targets Ports. Gherkin test realization maps to test/verification linkage, not automatically code implementation. | Both, with explicit realization semantics | +| `uses`: code collaboration | F04 Group 4, “Relationship Semantics” | C03 §§3–3.1 structural `dependsOn`, §4.2 annotation provenance; C02 §4.1; C01 §2.5 carries design dependencies separately | Reuse CodeNode `uses`. Keep authored collaboration distinct from inferred `imports`/`calls`; current model refuses that inferred structural layer. | Both; typed target IDs or validated JSDoc CSV | +| `depends-on`: genuine design need | F04 Group 4 retires separate tag; `uses` supplies the dependency | C01 §4 `dependsOn`; C03 §3.1 semantic dependency, §4.1 declared provenance; C02 §4.1 | Reuse Spec `dependsOn`. For an unrealized Studio part, declare its need on the Spec and traverse to the depended-on Spec’s code; do not invent a CodeNode for nonexistent code. | Spec declaration; no extra source tag | +| `enables`: reverse dependency view | F04 Group 4 retires authored form; F10 “Relationship Index” retains derived view | Reverse projection of C03 §3.1 `dependsOn`, preserving §4 provenance; C02 §7 makes projections derived | Compute reverse dependencies. `refines` means specialization and is not a general synonym for “enables.” A capability association uses C01 §4/C03 §§2.3, 3.1 membership when that is the actual assertion. | Derived; no authored marker | +| `status` | F04 Group 1; 09 “States”; F10 “Status and Lifecycle” | C01 §§1.4, 2.8–2.9; C03 §§2.1, 7; C02 §4.1 separates authored readiness and observed evidence | Keep Spec readiness and delivery facts separate. Do not transplant `completed` into a code marker or equate it with a passing test. Today’s structural facts already differ from v0’s richer observed-evidence model. | Spec declaration plus derived evidence; excluded from code markers | +| `see-also`: code refers to an unrealized design | F04 Group 4 explicitly defines informational reference without dependency | C01 §§2.5, 6, 8 permit design before binding; C03 §4.2 can record annotation provenance, but **no exact informational code→Spec edge is defined**; C02 §4.1 supplies extraction path | Add a non-conferring `references`/`seeAlso` edge and marker field. It must not be reduced to `satisfies`, `dependsOn`, or `refines`. This is an explicit extension to both v0 and today. | Both; typed Spec IDs or validated JSDoc CSV | + +All source-authored rows above use **annotation provenance** under C03 §4.2; Spec/component declarations use **declared provenance** under §4.1. If import/call extraction is later used to assist discovery, it remains **inferred** under §4.3, carrying its extractor/confidence and never silently replacing an authored relationship. Today’s `declared`/`anchored`/`inferred` claim classification does not reproduce v0’s full per-edge provenance payload or its implemented inference layer. + +**Naming an unrealized Spec without a delivery fact:** an existing reader’s marker can carry `references: ["spec:consumers.spec-studio.lenses"]` while omitting `satisfies`. The Spec exists as desired truth; the reader remains an actual source node; the annotation records relevance to that design. Only a separately authored realization edge participates in the `implemented` derivation. A wholly unrealized component belongs in declared design, not as a fabricated source anchor. Today’s mandatory `satisfies` and closed marker fields must change to admit this distinction. + +**Making the claim map a projection requires authored claims as well as annotations.** Give each independently assessable claim a stable Spec or addressable claim identity; record its intended relationships, actual realizing code/tests, and explicit decision departures. Then the shared `Reader → pages` design, reader/renderer/publisher roles, context membership, dependencies, and realization paths become queryable rather than reconstructed from similar-looking code. + +The exact five-way assessment cannot be derived from tag presence alone. “Partly” needs smaller claims or explicit coverage; “departed” needs a recorded decision relationship; “not built” requires an asserted scope or evidence beyond missing annotations. The minimal projection can truthfully show **unbound**, **linked as design context**, **realized**, and **verified by a named test**, while preserving authored assessments where the graph lacks sufficient evidence. + +## Evidence-based corrections + +- “About 50 tags shrank to 26” describes the formal-vocabulary cleanup; the standalone repository does not show a shipped 50-entry runtime registry becoming 26. +- The history identifies unused temporal/process machinery, duplicate identities, scan scope, and syntax handling; it does not establish dual TS/Gherkin extraction as the root cause. +- The JSDoc refusal’s rationale says it is “about prose, not identity,” but its operative alternative also refuses structured identity tags. +- Multiple typed/JSDoc extractors do not inherently create multiple read models: Gen 1 F10 and v0 C02 §7 explicitly converge them into one graph. +- Gen 1’s current `enables` is derived; the owner’s sample authored `enables` tag is surviving, ignored vocabulary. +- Gen 1 is not perfectly synchronized: formal specification, registry, extractor transport, and skill instructions disagree in the specific places reported above. +- “Fully type-safe” needs qualification: generated known-ID unions can check references; brands alone cannot, and neither compiler checks nor annotations prove behavioral fulfillment. +- V0 itself contains edge-example inconsistencies: C03 §11 reverses `satisfiedBy` relative to §3.1, and §4.2 illustrates `implements` to a Spec despite §3 defining Implementation→Port. The mapping above follows the explicit edge table. diff --git a/plans/39-adopter-driven-hardening.md b/plans/39-adopter-driven-hardening.md index 0189831f..4e868149 100644 --- a/plans/39-adopter-driven-hardening.md +++ b/plans/39-adopter-driven-hardening.md @@ -5,9 +5,14 @@ > for the owner to state `ready`. The entry-address engine is built: of the four Specs the owner > ruled, the prose-mentions validator and the open-section order carry `implemented` and wait for > the same statement, and the checked-mentions decision and contract declarations need no engine -> binding. One Spec stays held by an owner question. The backlog and readiness are read from the -> graph (recipes 1, 2, 9, 11). If this file and the graph disagree, the graph wins and this file -> is stale. +> binding. The one Spec an owner question held, the list rendering +> (`spec:decisions.authored-entry-order`), was ratified and built in round 2 (see "Round 2" at +> the end). Round 2 adds four Specs at `defined`, each built; promotion preflight (recipe 9) now +> reports the next rung's unmet clauses. Its slice of the original design is the Studio review +> workbench, captured as `pack:spec-studio-v1` ahead of its code. The round's one review returned +> nine findings, each ruled and folded in. The backlog and readiness are read from the graph +> (recipes 1, 2, 9, 11). If this file and the graph disagree, the graph wins and this file is +> stale. ## Why this arc @@ -324,3 +329,87 @@ Plan and execution stay separate. `npm run check` runs before any green claim. R promotion is a human statement after recipe 9. Checks police conformance and honesty, never content quality and never workflow. Close records re-derive their numbers and label them as re-derived. + +## Round 2 + +The adopter built a page per capability Pack on the engine at `5e29c03` and named five things the +page had to work around (its feedback of 2026-10-04, under `docs/feedback/` in the adopter's +repository). This round turns them into Specs on the branch `feature/adopter-round-2`, one pull +request to `main`, like pull request 27. + +### The owner's words + +The owner chose from the main thread's options on 2026-10-04: + +- O3, on the list rendering: "Ratify now". The record states `ready`, declares `supersedes` on the + shipped-projections freeze for open-section rendering, and keeps its decision text. +- O5, on a Pack's member order: "Members list on Pack node". The Pack node in the serialized graph + gains an ordered `members` list in authored order. Edges keep the global sort, so the + determinism rule stands. The reader's Pack context and the Design Review read the list. The + graph schema moves from `0.6.0` to `0.7.0`, one bump shared with O6. +- O6, on an open question's key: "Key in the marker". `- [blocking #aggregateReach] Does the owner + ...`, addressed as `spec:#question.aggregateReach`. The key is optional and unkeyed + questions stay lawful. The key grammar is the Design key grammar. A key is unique among one + Spec's open questions. A removed or renamed key makes every mention of its address an error, as + a missing Design entry does. The TypeScript carrier's object form is `{ question, blocking?, + key? }`. The checked-mentions record is revised, since it said an open question has no address. + +### The main thread's rulings + +These change no model. The owner reads them in the pull request. + +- R1. Ratification is an act: the record states `ready`, replaces its `dependsOn` on the freeze + with `supersedes`, drops its blocking question, and keeps its decision text. The rendering is + built in a separate commit. +- R2. Promotion preflight (recipe 9) keeps every field and gains the first unmet clause of its + next rung, the rung above the floor reached. A failure of the typed-dependency clause names the + targets that break it. The evaluator gains a target rung, exported for an adopter. +- R3. Address resolution is a new shipped recipe: one row per input address, in input order, and + a reason from a closed set when an address does not resolve. It adds no CLI verb. +- R4. Dependency cycles is a new shipped recipe: every strongly connected set of `dependsOn`, and + every Spec that depends on itself, each with one cycle through it as a closed path. +- R5. New behavior on a `ready` Spec is a new Spec at `defined` that the engine change binds, and + the build commit moves the parent's sentence, as this arc did before. A built `defined` Spec + stays in the drift alarm until the owner states `ready`. +- R6. This plan stays the live plan and gains this section. + +### The Specs + +Applied before any engine work, read from the graph with recipes 9 and 11: + +- `spec:decisions.authored-entry-order` states `ready` and supersedes the freeze, under O3 and R1. + `spec:consumers.design-review` and `spec:extraction.open-section-order` now say the open-section + entries render as a list, and `2d5d851` builds that rendering. +- `spec:decisions.checked-mentions` admits the `question` section of the entry address, under O6, + and the glossary's entry address says the same. +- `spec:extraction.pack-member-order`, with one bound example: the Pack node's `members` and + schema `0.7.0` (O5). +- `spec:consumers.agent-surface.address-and-cycle-recipes`: recipes 25 and 26 (R3, R4). +- `spec:validation.next-rung-floor`, with two bound examples: the target rung, the next rung's + failures in the reader and in recipe 9, and the typed-dependency targets (R2). Recipe 9 now + reports the next rung's unmet clauses beside the current floor's. +- `spec:model.open-question-keys`, with three bound examples: the key, its marker, its address + (O6). + +Each new Spec states `defined`, derives `ready`, and carries `implemented`. The design that +removes the builders' decisions is `design-sdp.md`, kept in the owner's context repository beside +the round's rulings. + +### Order of the build + +1. The list rendering of open sections, under the ratified record. +2. The Pack node's `members`, the reader's and the Design Review's member order, and schema + `0.7.0`. +3. The target rung, the typed-dependency targets, the reader's next-rung failures, and recipe 9. +4. The question key in both carriers, the serializer, the importer, the address grammar, the + mention validator, and recipes 20, 22, and 23. +5. Recipes 25 and 26, after step 4, since address resolution reads the `question` section. +6. The integrator re-derives the self-hosting pins and counts, and `npm run check` passes. +7. A representative slice of the original design under `docs/lineage/v0-design/`, captured as + Specs ahead of their code with the five changes in use. The slice is the Studio review + workbench, chapter 07's sections 1 to 3, 11 and 12, captured as `pack:spec-studio-v1`. + +### The review + +The round's one read-only review returned nine findings, the first on the defect planted in its +copy of the authoring skill, and the main thread ruled each and folded it in. diff --git a/specs/carrier/markdown-body-grammar.sdp.md b/specs/carrier/markdown-body-grammar.sdp.md index aa1c5f57..0f2d9e47 100644 --- a/specs/carrier/markdown-body-grammar.sdp.md +++ b/specs/carrier/markdown-body-grammar.sdp.md @@ -27,7 +27,7 @@ relations: - rule: The refused block shapes are read line by line in narrative, section prose, and list text: a line that opens with a pipe, a block-quote marker, whitespace, a star or plus bullet, an ordered-list marker, or a `<`, and a line that is only a thematic break or a setext underline. A table without outer pipes is prose to the parser. - rule: Raw HTML is refused in narrative, section prose, and list text, and is not scanned in the H1 title or inside fence steps. The test matches an HTML tag with its closing `>`, a comment delimiter, a declaration, or a processing instruction, so `a < b` and an unclosed `Promise#
.` for a `design` or `ui` key matching `^[a-z][A-Za-z0-9]*$`, and `null` for every other entry, including `description` and off-grammar keys. +- rule: Entry search gives `spec:#
.` for a `design` or `ui` key matching `^[a-z][A-Za-z0-9]*$` and `spec:#question.` for the text of an open question that carries a key, and `null` for every other entry, including `description` and off-grammar keys. - rule: The pinned declarations list 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: Each body composes the existing reader. None adds a reader join or a CLI verb, because a join freezes into the reader only at the second-caller bar. - rule: The recipe check executes each body as written, and every document that states the catalog's size moves with the catalog. - 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: The mention audit takes a list of mentioning Spec ids, with an empty list selecting the whole corpus. It reports target Spec pairs and unresolved token pairs, keeping every location in `at` as section and entry. Unresolved rows carry `reason: "spec" | "entry" | "malformed"` for an absent Spec, an absent own section key or `description`, or a refused token. Pairs with no declared relation in either direction, and pairs backed only by a declared relation from the target are separate lists. +- rule: The mention audit takes a list of mentioning Spec ids, with an empty list selecting the whole corpus. It reports target Spec pairs and unresolved token pairs, keeping every location in `at` as section and entry. Unresolved rows carry `reason: "spec" | "entry" | "malformed"` for an absent Spec; an absent own key of that `design` or `ui` section, `description` there, or a question key no open question of that Spec carries; or a refused token. Pairs with no declared relation in either direction, and pairs backed only by a declared relation from the target are separate lists. diff --git a/specs/consumers/design-review.sdp.md b/specs/consumers/design-review.sdp.md index b25219cc..5ad98426 100644 --- a/specs/consumers/design-review.sdp.md +++ b/specs/consumers/design-review.sdp.md @@ -18,14 +18,14 @@ relations: - rule: A human may use the review context when stating readiness, while validators check only the structural readiness floor and never record or require review approval. - rule: The MVP view is deterministic generated Markdown with an index and pages for Specs and Packs; richer visual representations remain outside this behavior. - rule: The page set is a function of the graph alone — it carries no timestamp, no commit, and no run identity — so two renders of the same corpus are byte-identical. -- rule: Rendering encodes by Markdown syntax context: prose and table fields escape structural characters, fenced JSON preserves authored keys and values through JSON encoding, and inline code uses a delimiter that preserves literal backticks. +- rule: Rendering encodes by Markdown syntax context: prose and table fields escape structural characters, each entry of a `design` or `ui` section renders as a list item in authored order with its key as inline code, as ruled by `spec:decisions.authored-entry-order`, fenced JSON preserves a structured value inside one entry through JSON encoding, and inline code uses a delimiter that preserves literal backticks. - rule: The realizing entrypoint is `renderDesignReview` in `src/projections/design-review.ts`, which reads the reader and returns pages; writing them is the caller's job. ## UI The generated Design Review exposes three page anatomies from the same graph. - specPage: A Spec page presents descriptors, readiness, relations, bindings, authored sections, and findings in one context. -- packPage: A Pack page presents framing, model references, and an ordered member table with each member's kind, altitude, readiness, and implementation and verifier bindings. +- packPage: A Pack page presents framing, model references, and a member table in the manifest's authored order with each member's kind, altitude, readiness, and implementation and verifier bindings. - indexPage: The index presents one sortable-style Markdown table for Specs and a linked bullet list for Packs, with stable links into their detail pages. ## Example space diff --git a/specs/consumers/reader.sdp.gherkin b/specs/consumers/reader.sdp.gherkin index 6494d726..1294248c 100644 --- a/specs/consumers/reader.sdp.gherkin +++ b/specs/consumers/reader.sdp.gherkin @@ -70,4 +70,5 @@ Feature: The reader bridges agent entry points to composable graph context Rule: The reader's `blastRadius` surface maps changed files to directly impacted Specs and Packs, their explicit one-hop at-risk neighbors, and every coverage-unknown file. Rule: Every impact and at-risk answer carries its reason as data — the changed file, the binding it travelled through, the connecting edge, and that edge's claim — so nothing about the reach is left to the caller's inference. Rule: File-level blast radius reports curated graph reach without claiming exhaustive symbol-level usage reach. + Rule: `packContext` lists one member row per manifest entry in the manifest's authored order, and its verifier gaps follow that order. Rule: The realizing entrypoint is `createReader` in `src/reader/reader.ts`. diff --git a/specs/consumers/spec-studio.components.sdp.md b/specs/consumers/spec-studio.components.sdp.md new file mode 100644 index 00000000..36a78083 --- /dev/null +++ b/specs/consumers/spec-studio.components.sdp.md @@ -0,0 +1,34 @@ +--- +id: spec:consumers.spec-studio.components +kind: contract +altitude: story +readiness: scoped +relations: + refines: spec:consumers.spec-studio + dependsOn: spec:consumers.spec-studio.data + decidedBy: spec:decisions.studio-web-components +--- +# The Studio's custom elements each render one view and embed anywhere + +## Intent +- outcome: Give the Studio a small set of custom elements that each render one view of the graph, so one view serves a Studio page, a status report, a pull request description or a slide export. +### 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? +- [non-blocking #harnessElement] The original's harness element takes a harness id and keeps its coverage current as a reader 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? + +## Contract +- Each element is self-contained and light on dependencies, and works embedded in any HTML page. +- Each element reads the one graph object its page sets, as `spec:consumers.spec-studio.data` states, and takes its subject as a graph id in an attribute. +- Every element's name starts with `sdp-`. + +## 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 missing tests. +- maturityMap: `sdp-maturity-map` with `pack-id` and `group-by`: a Pack's members by readiness, grouped by the named field, such as capability. +- harness: `sdp-harness` with `harness-id` and `auto-coverage`: an interactive scenario harness that keeps its coverage current. +- diagramView: `sdp-likec4-view` with `view-id`: a LikeC4 view, wrapping LikeC4's own runtime. +- 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 `id`: the composing panel on its own, in the place of the original's patch export button. +- validationFindings: `sdp-validation-findings` with `filter`: the validation report's findings for the Spec or Pack id the filter names. diff --git a/specs/consumers/spec-studio.data.sdp.md b/specs/consumers/spec-studio.data.sdp.md new file mode 100644 index 00000000..0380fbc8 --- /dev/null +++ b/specs/consumers/spec-studio.data.sdp.md @@ -0,0 +1,32 @@ +--- +id: spec:consumers.spec-studio.data +kind: contract +altitude: story +readiness: scoped +relations: + refines: spec:consumers.spec-studio + dependsOn: + - spec:consumers.reader + - spec:extraction.pack-member-order + - spec:validation.next-rung-floor + constrainedBy: spec:extraction.determinism +--- +# The Studio hydrates from the serialized graph and the reader's values + +## 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 designed-in and deferred, and no Spec states the shape of a slice. Does the Studio's data hold a slice, and which Spec would state it? + +## 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 nothing a page computes; a page derives no join, claim, delivery fact, readiness or floor failure from them. +- One graph writes byte-identical data files. +- 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. + +## 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: descriptors, narrative, sections, stated and derived readiness, `floorFailures` and `nextRungFailures`, relations both ways, implementation and verifier bindings, the oracle binding and the findings. +- packContexts: for each Pack, the reader's `packContext` value: framing, model references, the members in the manifest's authored order, verifier gaps 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. diff --git a/specs/consumers/spec-studio.intent-panel.sdp.md b/specs/consumers/spec-studio.intent-panel.sdp.md new file mode 100644 index 00000000..7ed19376 --- /dev/null +++ b/specs/consumers/spec-studio.intent-panel.sdp.md @@ -0,0 +1,37 @@ +--- +id: spec:consumers.spec-studio.intent-panel +kind: behavior +altitude: story +readiness: scoped +relations: + refines: spec:consumers.intent-composition + dependsOn: + - spec:consumers.edit-model + - spec:model.open-question-keys +--- +# A composing panel on every page gathers scoped intent for an agent + +## Intent +- outcome: Let a reader 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 +- [blocking #handOff] How does a static page hand composed intent to an agent: as a file the reader saves, as text the reader copies, or as a pull request? The original exports patch JSON or opens a pull request, and `spec:consumers.intent-composition` names no entrypoint for the composing surface. +- [non-blocking #patchLoop] The original stages each change as a JSON patch, checks its schema in the browser and its effect on the graph through the CLI or an agent, and applies it to canonical source; `spec:consumers.edit-model` has a view compose scoped intent that an agent turns into an ordinary source edit, with no patch loop. Does any part of the patch loop return, or is scoped intent the panel's only output? + +## Behavior +- rule: The composing panel is visible on every page. +- rule: The panel writes nothing: source changes only when an agent edits it and git records the edit, and the conformance and honesty checks judge that edit as they judge every edit. +- rule: A reader gathers intents across pages before handing them off. +- 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. + +## 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. +- panelActions: discard, hand off, and open as a pull request. +- composingActions: the actions on other panels that compose intent: resolve an open question or promote it to a decision record, propose or edit an example, propose or adjust a constraint target, and propose examples for missing combinations. + +## Example space +```gwt-vocabulary +Given the composing panel holds {gathered:number} gathered intents +When the reader refreshes the tab +Then the composing panel holds {kept:number} gathered intents +``` diff --git a/specs/consumers/spec-studio.lenses.sdp.md b/specs/consumers/spec-studio.lenses.sdp.md new file mode 100644 index 00000000..49db5488 --- /dev/null +++ b/specs/consumers/spec-studio.lenses.sdp.md @@ -0,0 +1,36 @@ +--- +id: spec:consumers.spec-studio.lenses +kind: behavior +altitude: story +readiness: scoped +relations: + refines: spec:consumers.spec-studio + dependsOn: + - spec:consumers.spec-studio.components + - spec:extraction.pack-member-order + - spec:observation.runtime-overlay + decidedBy: + - spec:decisions.binding-not-liveness + - spec:decisions.architectural-significance-rides-primitives +--- +# Four lenses read the graph as Packs, architecture, tests and evidence + +## Intent +- outcome: Let a reader see the graph four ways, by Pack, by architecture, by verification and by evidence, from one top navigation. +### Open questions +- [non-blocking #capabilityGrouping] The original's Packs lens groups Specs by Pack or by capability; a capability is a projection over high-altitude behavior Specs or a Pack grouping, no Capability Map projection exists, and grouping by architectural significance is derived from id families and the component graph. Which grouping does the capability tree read? +- [non-blocking #architectureVocabulary] The original's architecture lens draws runtime layers, ports and external systems beside components and their dependencies; `spec:decisions.architectural-significance-rides-primitives` admits no structural vocabulary beyond component membership and `uses`. Does the lens draw only what those edges hold? +- [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? +- [non-blocking #ruleCoverage] The original's tests lens names the rules of a Spec that no test exercises; an example verifies a whole Spec and a rule entry has no address, so the graph cannot tell which rule an example exercises. Does a rule gain an address, or does the lens name the uncovered Specs only? + +## Behavior +- rule: The Packs lens is the default landing page. +- rule: Clicking a component in the architecture lens opens its detail. +- 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 or by capability, each Pack's members in authored order. +- 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. +- evidenceLens: build provenance, SBOM links, OpenTelemetry runtime observations and deployment history. diff --git a/specs/consumers/spec-studio.pack.sdp.md b/specs/consumers/spec-studio.pack.sdp.md new file mode 100644 index 00000000..5a915c09 --- /dev/null +++ b/specs/consumers/spec-studio.pack.sdp.md @@ -0,0 +1,21 @@ +--- +id: pack:spec-studio-v1 +specs: + - spec:consumers.spec-studio + - spec:decisions.studio-html-surface + - 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.verification-panels + - spec:consumers.spec-studio.intent-panel + - spec:consumers.spec-studio.components + - spec:decisions.studio-web-components + - spec:consumers.spec-studio.responsive + - spec:consumers.spec-studio.sharing +modelRefs: + - spec:consumers.projections-model +--- +# 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. No member is built. A member reaches built code through the realized Spec it relates to where one exists; `spec:consumers.spec-studio.shell`, `spec:consumers.spec-studio.components` and `spec:decisions.studio-web-components` have none yet. diff --git a/specs/consumers/spec-studio.responsive.sdp.md b/specs/consumers/spec-studio.responsive.sdp.md new file mode 100644 index 00000000..f3ae10ec --- /dev/null +++ b/specs/consumers/spec-studio.responsive.sdp.md @@ -0,0 +1,31 @@ +--- +id: spec:consumers.spec-studio.responsive +kind: behavior +altitude: story +readiness: scoped +relations: + refines: spec:consumers.spec-studio + dependsOn: spec:extraction.claim-taxonomy +--- +# The Studio reads on a phone as on a desktop + +## Intent +- actor: A product manager or an executive reviewing a design on a phone. +- outcome: Let a design be reviewed on a phone through the same pages a desktop shows. +### Open questions +- [non-blocking #summaryMode] The original's summary mode hides low-confidence inferred edges; a claim is declared, anchored or inferred and carries no confidence, and the curated graph holds no inferred edge, which only the aspirational impact graph would add. What does summary mode hide? + +## Behavior +- rule: Every page of the Studio is responsive, so the same pages serve a desktop and a phone. + +## UI +- singleColumn: below about 720 pixels wide, every layout collapses to a single column. +- pinchZoom: on a touch device, every diagram zooms with a pinch. +- summaryMode: below a set screen width, the trace graph and the component diagrams switch to a summary mode that hides low-confidence inferred edges. + +## Example space +```gwt-vocabulary +Given a Studio page whose layout holds {wideColumns:number} columns +When the page is shown {width:number} pixels wide +Then the layout holds {columns:number} columns +``` diff --git a/specs/consumers/spec-studio.sdp.md b/specs/consumers/spec-studio.sdp.md new file mode 100644 index 00000000..e77adb1f --- /dev/null +++ b/specs/consumers/spec-studio.sdp.md @@ -0,0 +1,44 @@ +--- +id: spec:consumers.spec-studio +kind: behavior +altitude: feature +readiness: scoped +relations: + refines: spec:consumers.projections-model + dependsOn: + - spec:consumers.reader + - spec:consumers.edit-model + constrainedBy: spec:extraction.determinism + decidedBy: + - spec:decisions.studio-html-surface + - spec:decisions.studio-web-components + - spec:decisions.shipped-projections-frozen +--- +# The Spec Studio is a static HTML workbench over the one graph + +## Intent +- actor: A stakeholder who reviews a design, on a desktop or on a phone, and reads far more than they change. +- 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 +- [blocking #packageHome] Which package carries the Studio: the open-source `@libar-dev/software-delivery-protocol`, or a commercial `@libar-ai/` package? The Studio's deferral re-enters on a recorded ruling that names this home; earlier planning placed the Studio under `@libar-ai/`, and no Spec rules it. +- [blocking #unservedReader] Which reader does the Studio serve that the generated Markdown Design Review does not? The first adopter generates its own Pack page beside the Design Review, with the members in reading order, the clause that holds each below its next rung, the Specs outside the Pack they rest on, and their open questions with register rows, while the Design Review's Pack page lists the members and the verifier gaps. + +## Behavior +- 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: 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 reader wants leaves it as scoped intent for an agent, as `spec:consumers.edit-model` states. +- rule: 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: The Studio is built from custom elements, as `spec:decisions.studio-web-components` rules. +- 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 the extraction root stays byte-identical: {rootUntouched:boolean} +``` diff --git a/specs/consumers/spec-studio.sharing.sdp.md b/specs/consumers/spec-studio.sharing.sdp.md new file mode 100644 index 00000000..66e4cb8f --- /dev/null +++ b/specs/consumers/spec-studio.sharing.sdp.md @@ -0,0 +1,25 @@ +--- +id: spec:consumers.spec-studio.sharing +kind: behavior +altitude: story +readiness: scoped +relations: + refines: spec:consumers.spec-studio + dependsOn: spec:extraction.schema-versioning + constrainedBy: spec:extraction.determinism +--- +# A Studio build is stamped and shared from a static host + +## 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 +- [blocking #buildStamp] The original stamps the Studio's data with the git commit and the build time; the Studio renders byte-identical files from one graph, and `spec:extraction.determinism` keeps wall-clock timestamps and run-specific values out of generated output. Where does the stamp live, if anywhere? + +## 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. + +## Design +- dataStamp: each data file carries the graph's `schemaVersion`, the git commit SHA it was built from or `unknown` for a local build, and the build timestamp. +- previewPath: a pull request's preview is served at `/preview//`. diff --git a/specs/consumers/spec-studio.shell.sdp.md b/specs/consumers/spec-studio.shell.sdp.md new file mode 100644 index 00000000..ab179292 --- /dev/null +++ b/specs/consumers/spec-studio.shell.sdp.md @@ -0,0 +1,29 @@ +--- +id: spec:consumers.spec-studio.shell +kind: behavior +altitude: story +readiness: scoped +relations: + refines: spec:consumers.spec-studio + dependsOn: spec:consumers.spec-studio.data +--- +# The Studio's shell frames every page with navigation, a rail and a canvas + +## Intent +- outcome: Let a reader 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? +- [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? +- [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? + +## 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; each result shows the Spec's readiness and opens its page. +- rule: The rail's count of findings is the count of the validation report's findings, never a count the page derives. + +## UI +The parts of the shell, top to bottom and left to right. +- topNavigation: one bar with the entries Packs, Capabilities, Architecture, Tests, Evidence and Search; the first five open a lens, Packs and Capabilities being the two groupings of the Packs lens, and Search opens the search. +- leftRail: the current Pack's tree, its member Specs in the Pack's authored order, then the recent edits, then the count of validation findings by severity. +- mainCanvas: the page of the Spec or Pack selected in the rail or in a lens. +- search: indexes every Spec's title, id, terms and notes, and lists the matches, each with its readiness and a link into its page. diff --git a/specs/consumers/spec-studio.spec-page.sdp.md b/specs/consumers/spec-studio.spec-page.sdp.md new file mode 100644 index 00000000..74837e6a --- /dev/null +++ b/specs/consumers/spec-studio.spec-page.sdp.md @@ -0,0 +1,47 @@ +--- +id: spec:consumers.spec-studio.spec-page +kind: behavior +altitude: story +readiness: scoped +relations: + refines: spec:consumers.spec-studio + dependsOn: + - spec:consumers.spec-studio.data + - spec:consumers.derived-readiness-banner + - spec:consumers.binding-language-views + - spec:model.open-question-keys + decidedBy: spec:decisions.authored-entry-order +--- +# A Spec page shows one Spec in collapsible panels, in a fixed order + +## Intent +- outcome: Show everything the graph holds about one Spec on one page, in panels a reader 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? +- [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? +- [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 and an optional key, and git records when its line was added. Does the panel show an owner or a date, and from where? +- [non-blocking #runtimeComposition] The original's runtime panel binds a Fastify route to the Effect program it invokes, as `api:POST:/orders` invokes an Effect program with `R = CreateOrderUseCase`; shows the Effect layer `layer:CreateOrderUseCaseLive`, which provides `port:CreateOrderUseCase`, requires `port:OrderRepository` and `port:EventBus`, has the lifetime `scoped` and the test layer `layer:CreateOrderUseCaseTest`; names the external system `external:postgres`; and, for Awilix, binds the registration `createOrderUseCase` to `impl:CreateOrderUseCase` as `asClass CreateOrderUseCase` with the lifetime `SCOPED`, depending on `impl:OrderRepository` and `impl:EventBus`. An anchor carries identity only, and `component` and `uses` are its only structural fields. Does the 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? + +## 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 node a panel names is a link to its page. +- 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. + +## UI +The panels in page order. +- header: the Spec's id, its stated readiness beside the floor reached, its altitude and kind; its title; the Packs it belongs to; and its warning and error counts as pill badges. +- readinessColors: each rung has its own color, from a pinned palette that stays distinct under color blindness. +- intentPanel: the intent section as prose, then an open-questions panel headed with their count, each question with its text, whether it blocks, its key and the address the key gives it, and an action that composes intent scoped to that question. +- behaviorPanel: the rules, then the examples as 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 open their pages and show their source file and line on hover; below it, the `design` entries, then the decision records the Spec names by `decidedBy` with each decision's 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: the implementation, verifier and oracle bindings, each anchor's file and line as a link, with an action that opens the file at that line in the reader's editor. +- verificationPanel: the verifiers and the coverage of the example space, laid out as `spec:consumers.spec-studio.verification-panels` states. +- evidencePanel: builds, deployments, runtime observations and the SBOM, laid out as `spec:consumers.spec-studio.verification-panels` states. +- 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. diff --git a/specs/consumers/spec-studio.verification-panels.sdp.md b/specs/consumers/spec-studio.verification-panels.sdp.md new file mode 100644 index 00000000..1bb25d2e --- /dev/null +++ b/specs/consumers/spec-studio.verification-panels.sdp.md @@ -0,0 +1,37 @@ +--- +id: spec:consumers.spec-studio.verification-panels +kind: behavior +altitude: story +readiness: scoped +relations: + refines: spec:consumers.spec-studio.spec-page + dependsOn: + - spec:extraction.executable-contracts + - spec:observation.runtime-overlay + decidedBy: + - spec:decisions.binding-not-liveness + - spec:decisions.verification-posture-not-realization +--- +# The panels for examples, targets, verifiers and evidence + +## 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? +- [non-blocking #coverageSource] The original reads coverage from a harness's combinations; a parent's example space types its slots and the space contract lists each example's bound point, but only a slot typed as a union of literals has a finite set of values, and harnesses are outside this Pack. Does the grid enumerate those slots only? + +## 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: The evidence panel shows only runtime evidence the graph records, and reads not tracked while no delivery fact records an observation. +- rule: Each action on these panels composes scoped intent and changes no source. + +## UI +The panels' parts in page order. +- ruleList: each rule on its own line, followed by the example cards. +- exampleCard: one card per example: its title; its stated readiness and whether it is an Executable Spec; its given, when and then steps with their bound values; and actions to view its verifying test, open the example, and compose intent to edit it. +- 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 the last measured value next to it; 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 file and line as a link. +- coverageGrid: when the Spec owns an example space, one row per combination of slot values, marked covered when an example's bound point witnesses it and missing otherwise, with an action that composes intent proposing examples for the missing rows. +- evidencePanel: the last build with its SLSA attestation, the last deployment with its environment, the OpenTelemetry observation against the target with a link to the tool that holds it, and the CycloneDX SBOM to download. diff --git a/specs/decisions/authored-entry-order.sdp.md b/specs/decisions/authored-entry-order.sdp.md index 55c8eb3c..5edad9be 100644 --- a/specs/decisions/authored-entry-order.sdp.md +++ b/specs/decisions/authored-entry-order.sdp.md @@ -2,21 +2,17 @@ id: spec:decisions.authored-entry-order kind: decision altitude: feature -readiness: scoped +readiness: ready relations: refines: spec:consumers.design-review - dependsOn: - - spec:decisions.shipped-projections-frozen - - spec:extraction.open-section-order + dependsOn: spec:extraction.open-section-order + supersedes: spec:decisions.shipped-projections-frozen --- # The Design Review renders open-section entries as a list ## Intent - outcome: Render the entries of an open section as a readable list in authored order, so a signature or a step reads as text and not as an escaped JSON string. -### Open questions -- [blocking] This record changes a stated rule of the Design Review, so on ratification it declares `supersedes` on the shipped-projections freeze for open-section rendering and must pass the ADR three-part test. The envelope declares `dependsOn` until then, so a draft never reads as a landed supersession in the graph. Does the owner reopen the freeze now, or after `spec:extraction.open-section-order` has landed and the ordered JSON has been read? - ## Decision - context: The Design Review prints an open section as one fenced JSON object, so each entry's value is an escaped string. Authored order is restored by `spec:extraction.open-section-order` as an ordinary revision, because no Spec states the sort. Rendering is different: `spec:consumers.design-review` states that fenced JSON preserves authored keys and values, and the shipped projections are frozen, so a list rendering needs a superseding record. The first adopter corpus carries 762 keyed entries, and its reviewers read the carrier files instead of the review. - decision: The Design Review renders each entry of a `design` or `ui` section as a list item in authored order, its key as inline code. A string value renders as Markdown text through the prose escaping rule; a number or boolean renders as inline code; an array or object renders as a fenced JSON block nested under its item, so a structured value the TypeScript carrier admits survives without loss. The fenced-JSON rule of the Design Review stops covering an open section as a whole and keeps covering a structured value inside one entry. The freeze on the four shipped projections stands for everything else. diff --git a/specs/decisions/checked-mentions.sdp.md b/specs/decisions/checked-mentions.sdp.md index e583cbe1..cf98f892 100644 --- a/specs/decisions/checked-mentions.sdp.md +++ b/specs/decisions/checked-mentions.sdp.md @@ -10,16 +10,17 @@ relations: # A Spec id written in prose is a checked mention ## Intent -- outcome: Make a Spec id written in prose resolve, and make one keyed entry of an open section addressable, so a reference below the Spec level can be checked. +- outcome: Make a Spec id written in prose resolve, and make one keyed entry of an open section or one keyed open question addressable, so a reference below the Spec level can be checked. ### Open questions - [non-blocking] `mention` and `entry address` are candidate terms, defined in the glossary's flagged list. They move into the ratified tables when this record states `ready`. ## Decision - context: Specs name other Specs, and single entries of them, in prose. Referential integrity reads edges and Pack references only. A prose id that does not resolve passes validation, a mention that no relation backs hides a dependent from blast radius, and finding every Spec that repeats an entry is a text search. The first adopter corpus carried its design as keyed entries, and most of its prose mentions had no declared relation. -- decision: A Spec id in narrative or section text, outside `gwt` and `gwt-vocabulary` fences, is a mention. A mention that does not resolve is a conformance error. A mention of another Spec that shares no declared relation with the mentioning Spec, in either direction, is one warning per mentioning Spec and target Spec pair, whatever the number of locations; an address on the mentioning Spec's own id is checked for its entry and never warned. An entry address is a Spec id followed by `#`, the section name `design` or `ui`, a dot, and the entry's lower-camel ASCII key, as in `spec:#design.`; it addresses one keyed entry of that Spec and resolves when the Spec exists and that section holds the key as its own key, other than `description`. The `#` sub-part is reserved for the entry address in every namespace: a Spec's own id, a relation target, a Pack member or model reference, an anchor id or target, and the `specId` and `ref` builders all refuse it. A mention mints no edge, and the reader gains no join. +- decision: A Spec id in narrative or section text, outside `gwt` and `gwt-vocabulary` fences, is a mention. A mention that does not resolve is a conformance error. A mention of another Spec that shares no declared relation with the mentioning Spec, in either direction, is one warning per mentioning Spec and target Spec pair, whatever the number of locations; an address on the mentioning Spec's own id is checked for its entry and never warned. An entry address is a Spec id followed by `#`, the section name `design`, `ui`, or `question`, a dot, and the entry's lower-camel ASCII key, as in `spec:#design.` or `spec:#question.`. A `design` or `ui` address resolves when the Spec exists and that section holds the key as its own key, other than `description`; a `question` address resolves when the Spec exists and one of its open questions carries that key. The `#` sub-part is reserved for the entry address in every namespace: a Spec's own id, a relation target, a Pack member or model reference, an anchor id or target, and the `specId` and `ref` builders all refuse it. A mention mints no edge, and the reader gains no join. - rationale: The warning asks for a relation that already exists, so linkage keeps one home. A relation in either direction backs a mention because blast radius walks its one hop both ways, so a reverse edge already surfaces the dependent. One warning per pair keeps the report readable where a Spec names one neighbor in many bullets, and the mention audit keeps every location. - rationale: This applies the content-only sections ruling rather than contradicting it. A mention mints no edge, so no consumer branches on a reference union, and the warning names exactly the double-linkage drift that ruling calls legal and silent today. The id grammar already parses and formats a `#` sub-part; this record gives it its only meaning, and the dot inside the sub-part is the one grammar change. +- rationale: The owner ruled that an open question may carry an optional key in its marker, as in `- [blocking #aggregateReach] text`, addressed as `spec:#question.aggregateReach`. The key grammar is the Design key grammar, a key is unique among one Spec's open questions, and an unkeyed question stays lawful. A register that cites a question by its position breaks silently when a question above it moves or closes, and the first adopter's register had drifted in 47 of 151 such citations; a key breaks loudly where it is renamed. - rationale: The owner ruled that the address carries the section, because open-section keys are unique within a section and not within a Spec, so `spec:#design.shared` and `spec:#ui.shared` stay distinct when one carrier holds both; that `#` is refused in every identity, because neither this corpus nor the first adopter's carries one, re-measured as zero in both at `242d8e6`, and one meaning per sub-part keeps resolution a lookup instead of a precedence rule; and that one lower-camel key grammar serves every carrier, so an address is typeable from memory and a lawful TypeScript key outside it is simply unaddressable. - alternative: A new edge type for mentions costs the schema, the edge contract, the census, the Mermaid view, and every recipe that filters edges. The warning reaches the same dependents through relations that exist. - alternative: Leaving the check to each adopter's own lint fixes one corpus and no other. @@ -27,9 +28,9 @@ relations: - alternative: Refusing a key that appears under both Design and UI would shorten the address and make one section's keys depend on the other's. Refused by the owner. - alternative: Letting an identity with `#` outrank an address would keep today's grammar and make every address resolution a two-step lookup for a case no corpus has. Refused by the owner. - alternative: Warning only on a missing forward relation would turn a parent's mention of its own child into a finding the child's `refines` edge already answers. Refused by the owner. -- consequence: Only a keyed entry of `design` or `ui` is addressable. A Workflow flow, a Rule or Contract bullet, a Model term, and an open question have no address; a reference to one of them stays a Spec-level mention, and a sequence that must be addressed is authored as keyed entries. -- consequence: The key `description` names a section's leading prose and is never an entry, so an address ending in `.description` never resolves. A TypeScript key outside the lower-camel ASCII grammar is lawful and has no address. -- consequence: A renamed open-section key fails validation in every Spec that addresses it. +- consequence: Only a keyed entry of `design` or `ui` and a keyed open question are addressable. A Workflow flow, a Rule or Contract bullet, a Model term, and an unkeyed open question have no address; a reference to one of them stays a Spec-level mention, and a sequence that must be addressed is authored as keyed entries. +- consequence: The key `description` names the leading prose of a `design` or `ui` section and is never an entry there, so a `design` or `ui` address ending in `.description` never resolves. A question key is a value on the question, not a property beside section prose, so `description` is an ordinary question key. A TypeScript key outside the lower-camel ASCII grammar is lawful and has no address. +- consequence: A renamed or removed open-section key or question key fails validation in every Spec that addresses it. - consequence: Blast radius and delta review scope include a mentioning Spec once a declared relation backs its mention. - consequence: A family pattern written in id form reads as an unresolved mention, so such a pattern is written as plain words. - consequence: The id contract and its test move together: the stable-ids round-trip example re-binds from a sectionless `#` sub-part to an entry address, and the id tests move the sectionless form to the refused list. diff --git a/specs/decisions/question-key-rendering.sdp.md b/specs/decisions/question-key-rendering.sdp.md new file mode 100644 index 00000000..72f18bfd --- /dev/null +++ b/specs/decisions/question-key-rendering.sdp.md @@ -0,0 +1,22 @@ +--- +id: spec:decisions.question-key-rendering +kind: decision +altitude: feature +readiness: ready +relations: + refines: spec:consumers.design-review + dependsOn: spec:model.open-question-keys + supersedes: spec:decisions.shipped-projections-frozen +--- +# The Design Review shows an open question's key + +## Intent +- outcome: Show an open question's key on its Spec page, so a reader can cite the question's entry address from the page. + +## Decision +- context: `spec:model.open-question-keys` gives an open question an optional key in its marker and an entry address `spec:#question.`. The Design Review renders an open question as its escaped text, with the word blocking after a blocking one, and the shipped projections are frozen, so a reader of the page cannot see the key the address needs. The first adopter cites its open questions from a decision register and from generated pages. +- decision: The Design Review renders an open question that carries a key with the key as inline code, `#`, at the start of its item, before the question's text; a question without a key renders as before. The freeze on the four shipped projections stands for everything else. +- rationale: The key is the one part of the address the page did not show, and the `#` that opens it in the marker opens it on the page. +- alternative: Rendering the whole address beside each question repeats the Spec id on every question of its own page. +- alternative: Leaving the key off the page keeps the freeze whole and leaves the address to the carrier file and recipe 20. +- consequence: A self-hosted page whose Spec keys a question changes; the checkout-v1 goldens hold no keyed question and do not change. diff --git a/specs/decisions/studio-html-surface.sdp.md b/specs/decisions/studio-html-surface.sdp.md new file mode 100644 index 00000000..1f7363cb --- /dev/null +++ b/specs/decisions/studio-html-surface.sdp.md @@ -0,0 +1,29 @@ +--- +id: spec:decisions.studio-html-surface +kind: decision +altitude: feature +readiness: scoped +relations: + refines: spec:consumers.spec-studio + dependsOn: spec:extraction.regenerability +--- +# The Studio renders HTML, and the HTML stays derived + +## Intent +- outcome: Record why the Studio's surface is generated HTML rather than Markdown, and what keeps the HTML out of the diffs a reviewer reads. +### Open questions +- [blocking #primarySurface] Is the Studio's HTML the primary surface for a person reviewing a design, with Markdown as the fallback, as the original states, or one human projection beside the generated Markdown Design Review, which the consumers concept keeps as the MVP view after agents, the first sink? +- [non-blocking #generationCost] The original accepts HTML at about two to four times Markdown's generation time, with a negligible token cost under prompt caching and million-token windows, which prices a page a model writes; a projection is rendered by code from the graph, so does the argument become a bound on page weight or build time, or fall away? +- [non-blocking #canonicalCarrier] The original keeps git diffs over canonical TypeScript Specs; the carrier ruling makes Markdown the default carrier and the TypeScript DSL a lawful per-ID option. Does any part of the case for derived HTML depend on the carrier being TypeScript? + +## Decision +- context: When a model writes the Specs, a person's main act is review, and review reads tables, diagrams, code, comparisons and examples across many Specs. Markdown caps out at about a hundred lines, past which nobody reads it, its authors included; large Specs are common, and Markdown rots into a wall of text quickly. +- decision: The Studio renders generated HTML as its surface. The HTML is derived and never committed in raw form: git diffs stay over the canonical Spec source, and the Studio's files are regenerated from the graph. +- rationale: HTML conveys more per screen: tables, SVG diagrams, code blocks with syntax highlighting, embedded harnesses, interactive sliders, side-by-side comparisons and tabbed views are all native to it. +- rationale: HTML is shareable: a build uploaded to S3 or any static host is a link, with no rendering-engine mismatch between viewers. +- rationale: HTML is interactive: a reader can move an NFR target on a slider, toggle a scenario harness, or reorder a Pack's members by drag and drop, none of which Markdown can do. +- rationale: When a model writes the Specs, review is the person's main act, and HTML beats Markdown for review by a wide margin. +- rationale: The download is acceptable: HTML takes about two to four times Markdown's generation time, and with prompt caching and million-token windows the token cost is negligible for the value gained. +- alternative: Markdown as the primary surface diffs cleanly in git, but caps out at about a hundred lines and cannot be interactive. +- consequence: The principal trade-off of HTML, diffs that are noisier in git, is sidestepped because the HTML is derived and never committed. +- consequence: Markdown remains a fallback surface. diff --git a/specs/decisions/studio-web-components.sdp.md b/specs/decisions/studio-web-components.sdp.md new file mode 100644 index 00000000..4654e7d2 --- /dev/null +++ b/specs/decisions/studio-web-components.sdp.md @@ -0,0 +1,20 @@ +--- +id: spec:decisions.studio-web-components +kind: decision +altitude: feature +readiness: scoped +relations: + refines: spec:consumers.spec-studio +--- +# The Studio is built from custom elements, not a framework + +## Intent +- outcome: Record why the Studio's building blocks are Web Components rather than components of a framework. + +## Decision +- context: The Studio's views must work inside the Studio, standalone in any HTML container, and embedded in status reports, pull request descriptions and slide exports, and its assets should stay small and cache well. +- decision: The Studio is built from Web Components, custom elements written without React, Vue, Svelte or another framework. +- rationale: Custom elements carry no framework lock-in and work standalone in any HTML container. +- rationale: The Studio's assets stay small and cache-friendly. +- alternative: Components of React, Vue or Svelte would impose that framework on every page that embeds one of them. +- consequence: The few elements that need richer state, the harness and the scenario editor, may use a small reactive primitive inside themselves, such as `@lit/reactive-element` or a signals-style atom, without imposing a framework on the page that embeds them. diff --git a/specs/extraction/derive-graph.sdp.md b/specs/extraction/derive-graph.sdp.md index 3c29ca0f..1b44500c 100644 --- a/specs/extraction/derive-graph.sdp.md +++ b/specs/extraction/derive-graph.sdp.md @@ -25,6 +25,7 @@ forward pointer between records that still exist. - rule: Delivery facts are computed node facts: a resolving `satisfies` edge contributes `implemented`, and an enabled direct verifier contributes `has-verifier` only to its target. - rule: Inferred structural edges are advisory inputs to impact analysis and never become authoritative graph truth. - rule: The key order of `design`, `ui`, and `model.terms` is the authored order and part of the graph contract. +- rule: A Pack node carries its manifest's members in authored order, and the `belongsTo` edges that re-express them keep the global edge sort. ## Design diff --git a/specs/extraction/open-section-order.sdp.md b/specs/extraction/open-section-order.sdp.md index 000eb847..aa43c344 100644 --- a/specs/extraction/open-section-order.sdp.md +++ b/specs/extraction/open-section-order.sdp.md @@ -13,10 +13,10 @@ relations: - outcome: Carry the authored order of open-section entries and model terms through the serialized graph and the Design Review's key order. ## Rule -- The entry order of `design`, `ui`, and `model` terms is authored content. Extraction keeps it, the serialized graph emits it, the Design Review's fenced JSON follows it for open sections, and the Model table follows it for terms. +- The entry order of `design`, `ui`, and `model` terms is authored content. Extraction keeps it, the serialized graph emits it, the Design Review's entry list follows it for open sections, and the Model table follows it for terms. - Authored order is a function of the committed source, so two derivations of one commit stay byte-identical. What is given up is invariance under permutation: two carriers that author the same entries in different orders derive different graph bytes, and parity for these sections means the same entries in the same order. - A key is integer-like when it matches `^(0|[1-9][0-9]*)$`. The Markdown carrier refuses an integer-like Model term with the structure finding `model terms must not be integer-like` and drops the carrier, as it does for every structure refusal; its open-section keys are lower-camel and cannot be integer-like. The TypeScript carrier reads each name under `design`, `ui`, or `model.terms` as the key JavaScript gives it. A name written as an identifier or a string is its text, and a numeric name is the string form of its number, so `1e3` is `"1000"`, `0x10` is `"16"`, and `0.0000001` is `"1e-7"`. A property, method, getter, or setter whose key is integer-like is refused with the finding `extract/unrecognized-property` at severity error, where that finding is otherwise a warning, and the message `property "" is refused: integer-like keys are not accepted in design, ui, or model terms`. Each refused member gets one finding whatever its value, and only that member drops; the Spec and its other keys stay. A numeric name whose key is not integer-like, such as `1.5`, drops as a non-static name, as it did before this rule. The Gherkin carrier has no open section. The owner ruled the refusal over an ordered-list representation; re-measured at `242d8e6`, neither this corpus nor the first adopter's carries such a key. - With integer-like keys refused, a plain object keeps insertion order, so the representation stays an object and no field shape changes. Authored key order is part of the graph contract. The derived graph establishes it, so the reader, the serialized graph, and the Design Review agree. In `design` and `ui` the section's own `description`, its leading prose, comes first, then every other key in authored order. `model.terms` keeps pure authored order, and a term named `description` is an ordinary term, because the Model section's prose lives at `model.description`. - The schema version moves from `0.5.0` to `0.6.0`, documented as a change of accepted ids, since the `#` sub-part becomes an entry address, and of ordering semantics, with unchanged field shapes. The serialized graph changes for every Spec whose entries are not already in code-unit order. - The reader exposes authored order as the key order of a Spec context's `sections.design`, `sections.ui`, and `sections.model.terms`; entry search rows follow it, and no reader method is added. -- This rule is an ordinary revision, and no Spec stated the sort it replaced. `canonicalDynamicSection` in `src/extract/serialize.ts`, `renderDynamicRecord` in `src/projections/design-review-markdown.ts`, and `renderModel` in `src/projections/design-review-section-content.ts` are the sites that keep authored order. The Design Review's encoding rule is untouched; rendering entries as a list is `spec:decisions.authored-entry-order`. +- This rule is an ordinary revision, and no Spec stated the sort it replaced. `canonicalDynamicSection` in `src/extract/serialize.ts`, `renderDynamicRecord` in `src/projections/design-review-markdown.ts`, and `renderModel` in `src/projections/design-review-section-content.ts` are the sites that keep authored order. Rendering entries as a list is a change to the Design Review's encoding rule, ruled by `spec:decisions.authored-entry-order`; this rule supplies only the order the list follows. diff --git a/specs/extraction/pack-member-order.manifest-order-kept.sdp.md b/specs/extraction/pack-member-order.manifest-order-kept.sdp.md new file mode 100644 index 00000000..16526d43 --- /dev/null +++ b/specs/extraction/pack-member-order.manifest-order-kept.sdp.md @@ -0,0 +1,23 @@ +--- +id: spec:extraction.pack-member-order.manifest-order-kept +kind: example +altitude: story +readiness: defined +relations: + refines: spec:extraction.pack-member-order + verifies: spec:extraction.pack-member-order +--- +# A manifest's member order survives into the graph and the reader + +## Intent +- outcome: Execute the member order rule on a manifest whose authored order is not id order. +- assumption: The world writes one Markdown Pack manifest and three member Specs in one extraction root. Each member is a story-altitude behavior Spec stating `idea` with an Intent outcome and no relations. Lists are written as ids joined by a comma and a space. + +```gwt +Given a Markdown Pack manifest whose specs list reads {manifestOrder: "spec:probe.zeta, spec:probe.alpha, spec:probe.mid"} +When the graph is derived and serialized +Then the serialized Pack node lists the members {serializedMembers: "spec:probe.zeta, spec:probe.alpha, spec:probe.mid"} +Then the serialized belongsTo edges run from {edgeSources: "spec:probe.alpha, spec:probe.mid, spec:probe.zeta"} +Then the reader's Pack context lists the members {readerMembers: "spec:probe.zeta, spec:probe.alpha, spec:probe.mid"} +Then the payload declares the schema version {schemaVersion: "0.7.0"} +``` diff --git a/specs/extraction/pack-member-order.sdp.md b/specs/extraction/pack-member-order.sdp.md new file mode 100644 index 00000000..b8b57437 --- /dev/null +++ b/specs/extraction/pack-member-order.sdp.md @@ -0,0 +1,34 @@ +--- +id: spec:extraction.pack-member-order +kind: rule +altitude: story +readiness: defined +relations: + refines: spec:extraction.derive-graph + dependsOn: spec:carrier.markdown-pack-authoring + constrainedBy: spec:extraction.determinism +--- +# A Pack node keeps its members in authored order + +## Intent +- problem: A Pack manifest lists its members in the order its author chose to read them, and the graph loses that order: the serialized edges sort by from, type, and to, and the reader's Pack context sorts members by id, so an adopter reads the manifest file to recover a reading order the graph was given. +- outcome: Carry a Pack's members in the manifest's authored order through the serialized graph, the reader's Pack context, and the Design Review's Pack page, while the edges keep their global sort. + +## Rule +- A Pack node carries `members`, the manifest's `specs` list in authored order, a repeat included where the carrier admits one. The `belongsTo` edges re-express the same list, one edge per entry, so the members of a Pack node and the sources of its `belongsTo` edges are one list read twice and never disagree in a derived graph. +- `members` is always present on a Pack node, and an empty manifest list derives `members: []`. The serialized Pack node writes its fields in the order `id`, `nodeType`, `claim`, `title`, `framing`, `file`, `members`, `modelRefs`, omitting an absent optional field. +- Nodes keep their sort by id and edges keep their sort by from, type, and to. Authored member order is content of the Pack node, as authored entry order is content of a Spec's open sections, so two derivations of one commit stay byte-identical and two manifests that list the same members in different orders derive different Pack nodes. +- The reader's Pack context lists one member row per entry of `members`, in that order, and its verifier gaps follow the same order. The Design Review's Pack page renders its member table and its verifier gap list in that order. +- The schema version moves from `0.6.0` to `0.7.0`, documented as a field added to the Pack node and an optional field added to an open question, with node and edge order unchanged. One bump covers both additions. +- A repeated member is kept only where the carrier admits it. The TypeScript carrier carries a repeated member into `members` and the reader's rows as the manifest wrote it, and the repetition stays the pack-coherence error it is today, counted on the `belongsTo` edges. The Markdown carrier refuses a repeated member in a Pack manifest before derivation with `extract/invalid-frontmatter` and the message `duplicate entry in specs`, as it does today. +- The realizing sites are `derivePackNode` and the membership loop in `src/extract/derive.ts`, the Pack case of `canonicalNode` in `src/extract/serialize.ts`, and `packContext` in `src/reader/reader.ts`. + +## Example space +```gwt-vocabulary +Given a Markdown Pack manifest whose specs list reads {manifestOrder:string} +When the graph is derived and serialized +Then the serialized Pack node lists the members {serializedMembers:string} +Then the serialized belongsTo edges run from {edgeSources:string} +Then the reader's Pack context lists the members {readerMembers:string} +Then the payload declares the schema version {schemaVersion:string} +``` diff --git a/specs/extraction/schema-versioning.declared-version.sdp.md b/specs/extraction/schema-versioning.declared-version.sdp.md index 33d866f5..c8a83f52 100644 --- a/specs/extraction/schema-versioning.declared-version.sdp.md +++ b/specs/extraction/schema-versioning.declared-version.sdp.md @@ -15,5 +15,5 @@ relations: ```gwt Given a graph derived from the authored spec {specId: "spec:probe.schema-versioning"} When the graph payload is serialized -Then the payload declares the schema version {schemaVersion: "0.6.0"} +Then the payload declares the schema version {schemaVersion: "0.7.0"} ``` diff --git a/specs/model/open-question-keys.keyed-question-resolves.sdp.md b/specs/model/open-question-keys.keyed-question-resolves.sdp.md new file mode 100644 index 00000000..d9bdf7d2 --- /dev/null +++ b/specs/model/open-question-keys.keyed-question-resolves.sdp.md @@ -0,0 +1,24 @@ +--- +id: spec:model.open-question-keys.keyed-question-resolves +kind: example +altitude: story +readiness: defined +relations: + refines: spec:model.open-question-keys + verifies: spec:model.open-question-keys +--- +# A keyed question is reified and its address resolves + +## Intent +- outcome: Execute the key grammar and the question address on one keyed question beside an unkeyed one. +- assumption: The world writes two Markdown carriers in one extraction root. The first is a story-altitude rule Spec stating `scoped` with an Intent outcome, one rule, and one declared `refines` on the second; its Open questions hold the entries written in `questions`, one line each, where the slot joins them with a semicolon and a space. The second is a story-altitude rule Spec stating `idea` with an Intent outcome and one rule; it names the address in its narrative and declares `dependsOn` on the first. The messages of the extraction findings are joined by a semicolon and a space, and an empty string names no finding; the keys of every Spec in the graph are joined by a comma and a space. + +```gwt +Given a Markdown Spec carrier {specId: "spec:probe.subject"} whose open questions read {questions: "- [blocking #aggregateReach] Does the owner widen the aggregate?; - [non-blocking] Is the name final?"} +Given a second Spec that names {address: "spec:probe.subject#question.aggregateReach"} in its narrative and declares dependsOn on the first +When the extractor reifies both carriers and the graph is validated +Then the first carrier is reified: {reified: true} +Then the extraction findings name {extractMessage: ""} +Then the graph holds the question keys {keys: "aggregateReach"} +Then the report holds {mentionErrors: 0} prose-mention errors +``` diff --git a/specs/model/open-question-keys.renamed-key-breaks.sdp.md b/specs/model/open-question-keys.renamed-key-breaks.sdp.md new file mode 100644 index 00000000..32f7ec51 --- /dev/null +++ b/specs/model/open-question-keys.renamed-key-breaks.sdp.md @@ -0,0 +1,24 @@ +--- +id: spec:model.open-question-keys.renamed-key-breaks +kind: example +altitude: story +readiness: defined +relations: + refines: spec:model.open-question-keys + verifies: spec:model.open-question-keys +--- +# A renamed question key fails the mention that addresses it + +## Intent +- outcome: Execute the missing-entry error on an address whose question key the target no longer carries. +- assumption: The world is the one of the keyed-question example, with the key renamed in the first carrier and the address in the second left as it was. + +```gwt +Given a Markdown Spec carrier {specId: "spec:probe.subject"} whose open questions read {questions: "- [blocking #aggregateScope] Does the owner widen the aggregate?"} +Given a second Spec that names {address: "spec:probe.subject#question.aggregateReach"} in its narrative and declares dependsOn on the first +When the extractor reifies both carriers and the graph is validated +Then the first carrier is reified: {reified: true} +Then the extraction findings name {extractMessage: ""} +Then the graph holds the question keys {keys: "aggregateScope"} +Then the report holds {mentionErrors: 1} prose-mention errors +``` diff --git a/specs/model/open-question-keys.repeated-key-refused.sdp.md b/specs/model/open-question-keys.repeated-key-refused.sdp.md new file mode 100644 index 00000000..fc242c91 --- /dev/null +++ b/specs/model/open-question-keys.repeated-key-refused.sdp.md @@ -0,0 +1,24 @@ +--- +id: spec:model.open-question-keys.repeated-key-refused +kind: example +altitude: story +readiness: defined +relations: + refines: spec:model.open-question-keys + verifies: spec:model.open-question-keys +--- +# A repeated question key refuses its carrier + +## Intent +- outcome: Execute the uniqueness refusal, and show that the address then names a missing Spec. +- assumption: The world is the one of the keyed-question example, with two questions in the first carrier that carry the same key. The first carrier is refused whole, so the graph holds no question key and the address's Spec is absent. + +```gwt +Given a Markdown Spec carrier {specId: "spec:probe.subject"} whose open questions read {questions: "- [blocking #aggregateReach] Does the owner widen the aggregate?; - [non-blocking #aggregateReach] Does the aggregate keep its name?"} +Given a second Spec that names {address: "spec:probe.subject#question.aggregateReach"} in its narrative and declares dependsOn on the first +When the extractor reifies both carriers and the graph is validated +Then the first carrier is reified: {reified: false} +Then the extraction findings name {extractMessage: "open question keys must be unique"} +Then the graph holds the question keys {keys: ""} +Then the report holds {mentionErrors: 1} prose-mention errors +``` diff --git a/specs/model/open-question-keys.sdp.md b/specs/model/open-question-keys.sdp.md new file mode 100644 index 00000000..a86f53c1 --- /dev/null +++ b/specs/model/open-question-keys.sdp.md @@ -0,0 +1,35 @@ +--- +id: spec:model.open-question-keys +kind: rule +altitude: story +readiness: defined +relations: + refines: spec:model.spec-sections + dependsOn: spec:carrier.markdown-body-grammar + decidedBy: spec:decisions.checked-mentions +--- +# An open question may carry a key that addresses it + +## Intent +- problem: An open question has no identity but its position and its text, so a register or a decision that cites one breaks silently when a question above it moves, closes, or is reworded. +- outcome: Let an author give an open question an optional key, so `spec:#question.` names it, resolves while it stands, and fails validation where it is renamed or removed. + +## Rule +- An open question is prose, or an object with `question`, an optional `blocking` flag, and an optional `key`. A key matches `^[a-z][A-Za-z0-9]*$`, the grammar of a Design key, and is unique among the open questions of one Spec. An unkeyed question stays lawful and has no address. +- In the Markdown carrier the key follows the flag inside the marker, after one space and `#`, as in `- [blocking #aggregateReach] Does the owner widen the aggregate?` or `- [non-blocking #pageHome] Where does the page live?`. A marker whose key text is empty or off the grammar is refused with `open question keys must be lower-camel ASCII`, and a key that repeats an earlier key of the same Spec is refused with `open question keys must be unique`, each as the structure finding `extract/invalid-markdown-structure` at the line of the entry, and the carrier is refused whole as for every structure refusal. +- In the TypeScript carrier the key is the `key` property of the question object. A key that is not a string on the grammar, or that repeats an earlier key of the same Spec, is refused with the finding `extract/unrecognized-property` at severity error and the message `property "intent.openQuestions[].key" is refused: open question keys must be lower-camel ASCII` or `property "intent.openQuestions[].key" is refused: open question keys must be unique`, with `` the zero-based position; only the key drops, and the question and the Spec stay. +- The serialized graph writes a question object's fields in the order `question`, `blocking`, `key`, omitting an absent one. The import emitter writes a keyed question's marker as `[ #]`, so a keyed TypeScript question round-trips into Markdown. +- The entry address gains the section `question`: `spec:#question.` parses, is refused in every id slot as every entry address is, and resolves when the Spec exists and one of its open questions carries that key. A mention whose question key no open question carries is the prose-mentions error for a missing entry, so renaming or removing a key fails validation in every Spec that addresses it. +- The open-question register reports each question's key, or null for an unkeyed question. The mention audit and entry search read the `question` address as they read a `design` or `ui` address, and entry search gives a keyed question's text the address `spec:#question.`. +- The realizing sites are `IntentOpenQuestion` in `src/model/sections.ts`, the open-question marker in `src/extract/markdown-body-owner-behavior.ts`, the question shape in `src/extract/reify.ts`, `canonicalOpenQuestion` in `src/extract/serialize.ts`, the Intent emission in `src/import/emit-markdown.ts`, the entry-address grammar in `src/ids.ts`, and `hasAddressedEntry` in `src/validate/validators.ts`. + +## Example space +```gwt-vocabulary +Given a Markdown Spec carrier {specId:string} whose open questions read {questions:string} +Given a second Spec that names {address:string} in its narrative and declares dependsOn on the first +When the extractor reifies both carriers and the graph is validated +Then the first carrier is reified: {reified:boolean} +Then the extraction findings name {extractMessage:string} +Then the graph holds the question keys {keys:string} +Then the report holds {mentionErrors:number} prose-mention errors +``` diff --git a/specs/model/stable-ids.unsectioned-address-refused.sdp.md b/specs/model/stable-ids.unsectioned-address-refused.sdp.md index 0a12875a..70131981 100644 --- a/specs/model/stable-ids.unsectioned-address-refused.sdp.md +++ b/specs/model/stable-ids.unsectioned-address-refused.sdp.md @@ -16,5 +16,5 @@ relations: Given the authored identifier {identifier: "spec:orders.create-order#valid-cart"} When the identifier is parsed Then parsing {outcome: "is refused"} -Then the refusal names the reason {reason: "entry address must be
. with section design or ui"} +Then the refusal names the reason {reason: "entry address must be
. with section design, ui, or question"} ``` diff --git a/specs/self-hosting.pack.sdp.md b/specs/self-hosting.pack.sdp.md index 32063129..fdf632c9 100644 --- a/specs/self-hosting.pack.sdp.md +++ b/specs/self-hosting.pack.sdp.md @@ -203,6 +203,29 @@ specs: - spec:validation.typed-dependency-floor.missing-targets - spec:validation.typed-dependency-floor.unsettled-fact - spec:validation.typed-dependency-floor.stated-readiness + - spec:extraction.pack-member-order + - spec:extraction.pack-member-order.manifest-order-kept + - spec:consumers.agent-surface.address-and-cycle-recipes + - spec:validation.next-rung-floor + - spec:validation.next-rung-floor.typed-dependency-targets + - spec:validation.next-rung-floor.blocking-question + - spec:model.open-question-keys + - spec:model.open-question-keys.keyed-question-resolves + - spec:model.open-question-keys.renamed-key-breaks + - spec:model.open-question-keys.repeated-key-refused + - spec:decisions.question-key-rendering + - spec:consumers.spec-studio + - spec:decisions.studio-html-surface + - 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.verification-panels + - spec:consumers.spec-studio.intent-panel + - spec:consumers.spec-studio.components + - spec:decisions.studio-web-components + - spec:consumers.spec-studio.responsive + - spec:consumers.spec-studio.sharing modelRefs: - spec:model.protocol-domain - spec:model.core-model diff --git a/specs/validation/next-rung-floor.blocking-question.sdp.md b/specs/validation/next-rung-floor.blocking-question.sdp.md new file mode 100644 index 00000000..816cf164 --- /dev/null +++ b/specs/validation/next-rung-floor.blocking-question.sdp.md @@ -0,0 +1,24 @@ +--- +id: spec:validation.next-rung-floor.blocking-question +kind: example +altitude: story +readiness: defined +relations: + refines: spec:validation.next-rung-floor + verifies: spec:validation.next-rung-floor +--- +# A scoped Spec held by a blocking question is told the clause + +## Intent +- outcome: Execute the next rung's evidence on a Spec whose `defined` floor fails on a blocking open question, where the failure names no target. +- assumption: The world holds two story-altitude rule Specs with an Intent outcome and one rule each. The subject declares one `refines` on the other, its parent, which states `ready`, and records one blocking open question. No anchor is present. A failure with no targets is written as an empty string. + +```gwt +Given the graph holds a rule spec {specId: "spec:probe.held"} stating readiness {statedReadiness: "scoped"} +Given the spec {structure: "records a blocking open question"} +When the reader builds the spec's context +Then the floor reached is {floorReached: "scoped"} and the next rung is {nextRung: "defined"} +Then the next rung's first unmet clause is {clauseId: "no-blocking-open-questions"} +Then that failure names the targets {targets: ""} +Then the stated rung's floor failures number {currentFailures: 0} +``` diff --git a/specs/validation/next-rung-floor.sdp.md b/specs/validation/next-rung-floor.sdp.md new file mode 100644 index 00000000..b105b03e --- /dev/null +++ b/specs/validation/next-rung-floor.sdp.md @@ -0,0 +1,33 @@ +--- +id: spec:validation.next-rung-floor +kind: rule +altitude: story +readiness: defined +relations: + refines: spec:validation.readiness-floor + dependsOn: spec:validation.typed-dependency-floor +--- +# The floor names what blocks the next rung + +## Intent +- problem: The floor is evaluated only at the stated rung, so promotion preflight answers no unmet clause for a Spec that states `defined` and cannot state `ready`, and an author or an adopter's page walks the floor table one rung up with its own code. A failed typed-dependency clause names the clause and not the dependencies that break it, so the same page recomputes which targets stand below `defined`. +- outcome: Let the one evaluator answer for any rung, name the next rung's unmet clauses in the reader, and name the targets that fail the typed-dependency clause, so no consumer evaluates the floor a second way. + +## Rule +- The evaluator takes an optional target rung. Given one, it evaluates the cumulative clauses of every rung up to and including that rung; without one, it evaluates up to the stated rung, as before. A Spec of an unratified kind, or an evaluation with no ratified rung to reach, yields no failure, and the descriptor conformance error owns that case. +- The next rung is the rung above derived readiness: `idea` when no rung derives, and none when derived readiness is `ready`. Because derived readiness is the highest rung whose cumulative clauses all pass, the failures at the next rung are that rung's own clauses, and there is at least one whenever a next rung exists for a Spec of a ratified kind. +- The reader's Spec context carries the next rung's failures beside the stated rung's failures, computed by the same evaluator, and an empty list when there is no next rung. This is a field on the existing per-Spec context, not a new reader method, and it meets the second-caller bar: promotion preflight and the first adopter's Pack page both need it, and the page's own walk read the rung above the stated one where preflight reads the rung above the floor reached. +- A failure of the typed-dependency clause carries its targets: every distinct resolving `refines`, `dependsOn`, `constrainedBy`, or `decidedBy` target that states a rung below `defined`, or that is not a Spec, each with its relation type, its id, and its stated rung when it is a Spec. Targets sort by relation type in that order and then by id in code-unit order. No other clause carries targets. +- Promotion preflight keeps every field it reports and adds the next rung's failures and the first of them, each failure with its targets when it has them. The next rung's first unmet clause is a report and never a promotion: stating any rung stays a human's edit. +- The realizing sites are `evaluateReadinessFloor` and the typed-dependency clause in `src/validate/readiness-floor.ts`, and `specContext` in `src/reader/reader.ts`. + +## Example space +```gwt-vocabulary +Given the graph holds a rule spec {specId:string} stating readiness {statedReadiness:"scoped"|"defined"} +Given the spec {structure:"depends on a spec stating scoped"|"records a blocking open question"} +When the reader builds the spec's context +Then the floor reached is {floorReached:"scoped"|"defined"} and the next rung is {nextRung:"defined"|"ready"} +Then the next rung's first unmet clause is {clauseId:string} +Then that failure names the targets {targets:string} +Then the stated rung's floor failures number {currentFailures:number} +``` diff --git a/specs/validation/next-rung-floor.typed-dependency-targets.sdp.md b/specs/validation/next-rung-floor.typed-dependency-targets.sdp.md new file mode 100644 index 00000000..50c2e82b --- /dev/null +++ b/specs/validation/next-rung-floor.typed-dependency-targets.sdp.md @@ -0,0 +1,24 @@ +--- +id: spec:validation.next-rung-floor.typed-dependency-targets +kind: example +altitude: story +readiness: defined +relations: + refines: spec:validation.next-rung-floor + verifies: spec:validation.next-rung-floor +--- +# A defined Spec resting on a scoped dependency is told which one + +## Intent +- outcome: Execute the next rung's evidence on a Spec whose own floor clears `defined` and whose `ready` floor fails on one dependency. +- assumption: The world holds three story-altitude rule Specs with an Intent outcome and one rule each. The subject declares `dependsOn` on the basis Spec named in the targets, which states `scoped` and declares `refines` on a third Spec stating `idea`. No Spec records an open question and no anchor is present. A target is written as its relation type, its id, and its stated rung in parentheses. + +```gwt +Given the graph holds a rule spec {specId: "spec:probe.subject"} stating readiness {statedReadiness: "defined"} +Given the spec {structure: "depends on a spec stating scoped"} +When the reader builds the spec's context +Then the floor reached is {floorReached: "defined"} and the next rung is {nextRung: "ready"} +Then the next rung's first unmet clause is {clauseId: "typed-dependency-targets-are-defined"} +Then that failure names the targets {targets: "dependsOn spec:probe.basis (scoped)"} +Then the stated rung's floor failures number {currentFailures: 0} +``` diff --git a/specs/validation/pack-coherence.sdp.md b/specs/validation/pack-coherence.sdp.md index a057b839..3d63c278 100644 --- a/specs/validation/pack-coherence.sdp.md +++ b/specs/validation/pack-coherence.sdp.md @@ -14,6 +14,7 @@ relations: ## Rule - Pack membership must not repeat a Spec, and every modelRef must resolve to a model-kind Spec. - Membership is counted on the derived belongsTo edges the manifest re-expresses, so a repeated manifest entry is named once per repeated member. +- A Pack's members list and its belongsTo edges agree: every listed member has one belongsTo edge into the Pack, and every belongsTo edge into the Pack comes from a listed member. A listed member with no edge, a listed member that is no Spec and no edge carries, and an edge from an unlisted source are each one error, in authored order, so the Pack context, a Spec's packs, and blast radius read the same membership. An edge whose source is absent or no Spec stays referential integrity's or the edge contract's finding, never a second one here. - There is no duplicated-intent check on a Pack: a Pack states no system truth to duplicate, and semantic overlap among its members remains human or agent review rather than validator judgment. This lets a coherent group contain low-detail Specs without turning grouping into implementation demand. - The realizing validator entrypoint is `checkPackCoherence` in `src/validate/validators.ts`. diff --git a/specs/validation/prose-mentions.sdp.md b/specs/validation/prose-mentions.sdp.md index e904ea50..a4cc9cd5 100644 --- a/specs/validation/prose-mentions.sdp.md +++ b/specs/validation/prose-mentions.sdp.md @@ -18,7 +18,7 @@ relations: - A mention starts at `spec:` where the character before it, if any, is not an ASCII letter, an ASCII digit, or `-`. A backslash before ASCII punctuation is read as that punctuation, as Markdown reads an escape. The token ends at ASCII whitespace, at one of the ASCII delimiters `` ` `` `"` `'` `(` `)` `[` `]` `{` `}` `<` `>` `|`, or at a character outside ASCII that is whitespace, U+0085 included, punctuation other than connector punctuation, a symbol, or a space, line, or paragraph separator, so every dash, ellipsis, typographic quote, and arrow ends it. Every other character stays in the token, letters, combining marks, digits, connector punctuation, control characters other than whitespace, and format, private-use, and unassigned characters among them. Trailing `.` `,` `;` `:` `!` `?` `*` `_` `~` are then removed after the prefix, so a sentence, a list, or emphasis may end in an id. When nothing remains after `spec:`, there is no mention, so a bare prefix or a placeholder such as `spec:` stays prose. The whole token goes to `parseId`, so an underscore, a slash, a comma, a control character, or any character outside ASCII that stays in it refuses the whole token as malformed; the token is never cut short to the valid id before that character, and a refused token counts toward no pair. A token equal to the scanning Spec's own id is skipped. - A token that fails `parseId` is an error with the message `Mention "" in "" at is not a Spec id or entry address: `, where `` is the grammar's refusal text with its `Invalid ID "": ` prefix stripped. - A token whose Spec part names no Primitive node is an error with the message `Mention in "" at points to missing target "".`, followed by the referential-integrity nearest-id suggestion when one is unique. -- A token whose Spec exists and whose entry address names no key of that section, or names `description`, is an error with the message `Mention in "" at points to missing entry "
." of "".` +- A token whose Spec exists and whose entry address names no key of that `design` or `ui` section, names `description` there, or names a question key no open question of that Spec carries, is an error with the message `Mention in "" at points to missing entry "
." of "".` - 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.`. - 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 "" in "" at locations, first at , 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 `` the count of distinct paths, written `1 location` when it is one, and `` 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. - 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/specs/validation/readiness-floor.sdp.md b/specs/validation/readiness-floor.sdp.md index b891ffaf..8c15c3e9 100644 --- a/specs/validation/readiness-floor.sdp.md +++ b/specs/validation/readiness-floor.sdp.md @@ -29,7 +29,7 @@ relations: - The anchor clause reads the bindings that are present, so a Spec carrying no anchor clears it — the floor never demands a binding an author has not made. - Only relations the Spec itself declares count toward the relation clauses; membership of a Pack is derived from the manifest and never stands in for an authored relation. - Every clause stated here is kind-blind. The two evidence clauses are the one kind-conditional place in the floor, and what counts as a kind's natural evidence is stated in full by the refining Spec that carries the per-kind evidence table. -- One clause table serves both readings: it checks the readiness an author states, and it yields derived readiness — the highest rung whose cumulative clauses all pass — which is read beside the stated rung and never overwrites it. +- One clause table serves both readings: it checks the readiness an author states, and it yields derived readiness — the highest rung whose cumulative clauses all pass — which is read beside the stated rung and never overwrites it, and it answers for a target rung an evaluation names, which yields the next rung's unmet clauses. - The floor is the mechanism while the specific clause thresholds are one chosen representation, so a team-overridable floor configuration is a designed-for deferral rather than a landed capability: no validator reads a per-team floor setting, and the shipped clause table is the only floor any Spec is checked against. - The floor table in `src/validate/readiness-floor.ts` is the clause set's code-level source of truth and the realizing entrypoint. The clauses stated here and the rows of that table are one law read twice, so any disagreement between them is drift to resolve on one side, never a second floor. diff --git a/specs/validation/typed-dependency-floor.sdp.md b/specs/validation/typed-dependency-floor.sdp.md index 174998e4..359c9e0c 100644 --- a/specs/validation/typed-dependency-floor.sdp.md +++ b/specs/validation/typed-dependency-floor.sdp.md @@ -19,7 +19,7 @@ relations: - The clause stays kind-blind and reads resolving targets only. An unresolved target remains the relation-resolution clause's failure, never a second one. - `verifies` and `supersedes` stay outside the clause. A verifier's rung is independent of the Spec it verifies, and a replacement decision does not rest on the record it supersedes. - An unsettled fact is stated the way any unsettled truth is. A constraint Spec that records a blocking open question clears at most `scoped`. When it states a rung below `defined`, a Spec bounded by it may state `defined` if its own floor clears, but cannot lawfully state `ready`. -- The floor keeps one target clause, `typed-dependency-targets-are-defined`, in `src/validate/readiness-floor.ts`. Its parent `spec:validation.readiness-floor` states the same target bound. +- The floor keeps one target clause, `typed-dependency-targets-are-defined`, in `src/validate/readiness-floor.ts`. Its parent `spec:validation.readiness-floor` states the same target bound. A failure of the clause names every target that breaks it. ## Example space ```gwt-vocabulary diff --git a/src/extract/derive.ts b/src/extract/derive.ts index 521230b4..121410cf 100644 --- a/src/extract/derive.ts +++ b/src/extract/derive.ts @@ -89,7 +89,15 @@ function derivePrimitiveNode(entry: ReifiedSpec): PrimitiveNode { }; } -function derivePackNode(entry: ReifiedPack): PackNode { +const packMemberOrderAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.pack-member-order"), + label: "keeps manifest member order on the Pack node", + satisfies: ref("spec:extraction.pack-member-order"), + component: componentAnchorId("component:protocol.extract"), +}); +void packMemberOrderAnchor; + +function derivePackNode(entry: ReifiedPack, memberIds: readonly string[]): PackNode { const title = entry.data.title; const framing = entry.data.framing; const modelRefs = entry.data.modelRefs; @@ -100,8 +108,9 @@ function derivePackNode(entry: ReifiedPack): PackNode { claim: "declared", ...(typeof title === "string" ? { title } : {}), ...(typeof framing === "string" ? { framing } : {}), - ...(Array.isArray(modelRefs) ? { modelRefs: modelRefs as readonly string[] } : {}), file: entry.file, + members: [...memberIds], + ...(Array.isArray(modelRefs) ? { modelRefs: modelRefs as readonly string[] } : {}), }; } @@ -178,12 +187,12 @@ export function deriveGraph( } for (const entry of packs) { - nodes.push(derivePackNode(entry)); - const memberIds = Array.isArray(entry.data.specs) ? (entry.data.specs as readonly string[]) : []; + nodes.push(derivePackNode(entry, memberIds)); + for (const memberId of memberIds) { edges.push({ from: memberId, diff --git a/src/extract/markdown-body-owner-behavior.ts b/src/extract/markdown-body-owner-behavior.ts index d67380ff..41cbc462 100644 --- a/src/extract/markdown-body-owner-behavior.ts +++ b/src/extract/markdown-body-owner-behavior.ts @@ -1,3 +1,5 @@ +import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; +import { codeAnchor } from "../model/code-anchor.js"; import type { Finding } from "../validate/contracts.js"; import { parseSectionContent } from "./markdown-body-content.js"; import type { MarkdownLine } from "./markdown-body-content.js"; @@ -16,19 +18,48 @@ import { addMarkdownFinding } from "./markdown-support.js"; interface OpenQuestion { readonly question: string; readonly blocking: boolean; + readonly key?: string; } +/** + * The open-question marker: the flag, then optionally one space, `#`, and the key, inside the + * brackets. The captured key is checked against the Design key grammar after the match, so an + * empty or off-grammar key is refused by name instead of falling through to another refusal. + */ +const OPEN_QUESTION_MARKER = /^\[(blocking|non-blocking)(?: #([^\]]*))?\] (.+)$/u; +const OPEN_QUESTION_KEY = /^[a-z][A-Za-z0-9]*$/u; + +const openQuestionKeysAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.open-question-keys"), + label: + "reads an open question's optional key from its Markdown marker and refuses a bad or repeated key", + satisfies: ref("spec:model.open-question-keys"), + component: componentAnchorId("component:protocol.extract"), +}); +void openQuestionKeysAnchor; + function isOpenQuestionArray(value: unknown): value is readonly OpenQuestion[] { return ( Array.isArray(value) && value.every((item): item is OpenQuestion => { if (item === null || typeof item !== "object") return false; const candidate = item as Record; - return typeof candidate.question === "string" && typeof candidate.blocking === "boolean"; + return ( + typeof candidate.question === "string" && + typeof candidate.blocking === "boolean" && + (candidate.key === undefined || typeof candidate.key === "string") + ); }) ); } +/** The refusal a captured key earns, or undefined when the key is lawful and new in this Spec. */ +function openQuestionKeyRefusal(key: string, earlier: readonly OpenQuestion[]): string | undefined { + if (!OPEN_QUESTION_KEY.test(key)) return "open question keys must be lower-camel ASCII"; + if (earlier.some((question) => question.key === key)) return "open question keys must be unique"; + return undefined; +} + export function mapIntent( section: Record, target: Record, @@ -85,18 +116,27 @@ export function mapIntent( structureFinding(file, parsed.h3.line, "only ### Open questions is accepted under Intent"), ); for (const item of parsed.items) { - const question = /^\[(blocking|non-blocking)\] (.+)$/u.exec(item.text); - if (question?.[1] !== undefined && question[2] !== undefined) { + const question = OPEN_QUESTION_MARKER.exec(item.text); + if (question?.[1] !== undefined && question[3] !== undefined) { if (!item.afterH3 || parsed.h3?.text !== "### Open questions") addMarkdownFinding( findings, structureFinding(file, item.line, "open questions require ### Open questions"), ); else { - const entry = { question: question[2], blocking: question[1] === "blocking" }; - section.openQuestions = isOpenQuestionArray(section.openQuestions) - ? [...section.openQuestions, entry] - : [entry]; + const earlier = isOpenQuestionArray(section.openQuestions) ? section.openQuestions : []; + const key = question[2]; + const refusal = key === undefined ? undefined : openQuestionKeyRefusal(key, earlier); + if (refusal !== undefined) { + addMarkdownFinding(findings, structureFinding(file, item.line, refusal)); + continue; + } + const entry: OpenQuestion = { + question: question[3], + blocking: question[1] === "blocking", + ...(key === undefined ? {} : { key }), + }; + section.openQuestions = [...earlier, entry]; } continue; } diff --git a/src/extract/reify.ts b/src/extract/reify.ts index a285a80e..ee8ff05b 100644 --- a/src/extract/reify.ts +++ b/src/extract/reify.ts @@ -606,10 +606,15 @@ function reifyStaticArray( return { ok: true, value: values }; } +/** + * `placeholders` names the properties whose non-static value reifies to the mapped marker instead + * of failing the object — today only an open question's `key` (`reifyOpenQuestions`). + */ function reifyStaticObject( objectLiteral: ObjectLiteralExpression, path: string, bindings: ProtocolBindings, + placeholders?: ReadonlyMap, ): StaticResult { const value: Record = {}; const seenNames = new Set(); @@ -649,14 +654,61 @@ function reifyStaticObject( const result = reifyStaticValue(initializer, `${path}.${name}`, bindings); + if (result.ok) { + setOwn(value, name, result.value); + continue; + } + + if (placeholders?.has(name)) { + setOwn(value, name, placeholders.get(name)); + continue; + } + + return result; + } + + return { ok: true, value }; +} + +/** + * What a non-static open-question `key` reifies to. `checkOpenQuestionKeys` refuses it at the + * key's line exactly as it refuses a static key off the grammar, and deletes it, so the marker + * never reaches the graph. + */ +const NON_STATIC_OPEN_QUESTION_KEY = Symbol("non-static open question key"); +const OPEN_QUESTION_PLACEHOLDERS: ReadonlyMap = new Map([ + ["key", NON_STATIC_OPEN_QUESTION_KEY], +]); + +/** + * The one array that is not strict about one property: an open question's `key` is the key + * check's to refuse, so a non-static key drops alone with that check's error while the question + * and its siblings stay (`spec:model.open-question-keys`). Everything else keeps the strict array + * rule — a non-static `question`, a spread, or a non-static prose element still fails the whole + * `openQuestions` value, as `reifyStaticArray` would. + */ +function reifyOpenQuestions( + arrayLiteral: ArrayLiteralExpression, + path: string, + bindings: ProtocolBindings, +): StaticResult { + const values: unknown[] = []; + + for (const [index, element] of arrayLiteral.getElements().entries()) { + const elementPath = `${path}[${String(index)}]`; + const unwrapped = unwrapTransparent(element); + const result = Node.isObjectLiteralExpression(unwrapped) + ? reifyStaticObject(unwrapped, elementPath, bindings, OPEN_QUESTION_PLACEHOLDERS) + : reifyStaticValue(element, elementPath, bindings); + if (!result.ok) { return result; } - setOwn(value, name, result.value); + values.push(result.value); } - return { ok: true, value }; + return { ok: true, value: values }; } function containsDescription(value: unknown): boolean { @@ -717,7 +769,7 @@ const RECOGNIZED_SECTION_PROPERTIES: ReadonlyMap> = "openQuestions", ]), ], - ["intent.openQuestions[]", new Set(["question", "blocking"])], + ["intent.openQuestions[]", new Set(["question", "blocking", "key"])], ["behavior", new Set(["description", "rules", "examples", "flows", "exampleSpace"])], ["behavior.examples[]", GWT_PROPERTY_NAMES], ["behavior.exampleSpace", GWT_PROPERTY_NAMES], @@ -742,7 +794,7 @@ const AUTHORING_SHAPE_PATHS = new Set([ * Section content degrades property-by-property: a non-static property inside a section drops with * a warning while its static siblings survive. Lossiness recurses through object nesting only — * arrays stay strict (see `reifyStaticArray`), so a failure inside an array drops the owning - * property wholesale. + * property wholesale. The one exception is an open question's `key` (`reifyOpenQuestions`). */ function reifyObjectLossy( objectLiteral: ObjectLiteralExpression, @@ -829,7 +881,10 @@ function reifyObjectLossy( continue; } - const result = reifyStaticValue(initializer, propertyPath, bindings); + const result = + propertyPath === "intent.openQuestions" && Node.isArrayLiteralExpression(inner) + ? reifyOpenQuestions(inner, propertyPath, bindings) + : reifyStaticValue(initializer, propertyPath, bindings); if (result.ok) { setOwn(value, name, result.value); @@ -956,6 +1011,97 @@ function sanitizeSectionValue(node: Node, value: unknown, path: string): Section return { value: sanitized, issues }; } +/** The Design key grammar, which an open question's key shares. */ +const OPEN_QUESTION_KEY = /^[a-z][A-Za-z0-9]*$/u; + +/** + * The first plain property assignment of an object literal with the given name, the one lossy + * reification and sanitization keep when a name repeats. + */ +function firstPropertyNamed(node: Node, name: string): PropertyAssignment | undefined { + const unwrapped = unwrapTransparent(node); + + if (!Node.isObjectLiteralExpression(unwrapped)) { + return undefined; + } + + return unwrapped + .getProperties() + .find( + (property): property is PropertyAssignment => + Node.isPropertyAssignment(property) && readPropertyName(property) === name, + ); +} + +/** The line of `openQuestions[].key` in an authored Intent, or the Intent's own line. */ +function openQuestionKeyLine(intentNode: Node, index: number): number { + const fallback = intentNode.getStartLineNumber(); + const questions = firstPropertyNamed(intentNode, "openQuestions")?.getInitializer(); + const array = questions === undefined ? undefined : unwrapTransparent(questions); + + if (array === undefined || !Node.isArrayLiteralExpression(array)) { + return fallback; + } + + const element = array.getElements()[index]; + const key = element === undefined ? undefined : firstPropertyNamed(element, "key"); + + return key?.getStartLineNumber() ?? fallback; +} + +/** + * An open question's key is the Design key grammar and unique among one Spec's open questions. + * A key that is not a string on the grammar, or repeats an earlier key, drops alone with an error; + * the question and the Spec stay (`spec:model.open-question-keys`). A non-static key arrives here + * as `NON_STATIC_OPEN_QUESTION_KEY` and is refused as a key off the grammar. + */ +function checkOpenQuestionKeys( + intentNode: Node, + intent: unknown, + file: string, + subjectId: string | undefined, + findings: Finding[], +): void { + const questions = isUnknownRecord(intent) ? intent.openQuestions : undefined; + + if (!Array.isArray(questions)) { + return; + } + + const seen = new Set(); + + for (const [index, question] of questions.entries()) { + if (!isUnknownRecord(question) || !Object.hasOwn(question, "key")) { + continue; + } + + const key: unknown = question.key; + const lawful = typeof key === "string" && OPEN_QUESTION_KEY.test(key); + + if (lawful && !seen.has(key)) { + seen.add(key); + continue; + } + + const reason = lawful + ? "open question keys must be unique" + : "open question keys must be lower-camel ASCII"; + const path = `intent.openQuestions[${String(index)}].key`; + delete question.key; + findings.push( + createExtractFinding( + extractFindingIds.unrecognizedProperty, + "error", + `property "${path}" is refused: ${reason}`, + file, + openQuestionKeyLine(intentNode, index), + subjectId, + path, + ), + ); + } +} + function appendSectionPropertyFindings( issues: readonly SectionPropertyIssue[], file: string, @@ -1436,6 +1582,9 @@ function reifySpecCall( data[name] = sanitized.value; appendDropFindings(lossy.drops, file, subjectId, findings); appendSectionPropertyFindings(sanitized.issues, file, subjectId, findings); + if (name === "intent") { + checkOpenQuestionKeys(inner, sanitized.value, file, subjectId, findings); + } continue; } @@ -1461,6 +1610,9 @@ function reifySpecCall( const sanitized = sanitizeSectionValue(inner, result.value, name); data[name] = sanitized.value; appendSectionPropertyFindings(sanitized.issues, file, subjectId, findings); + if (name === "intent") { + checkOpenQuestionKeys(inner, sanitized.value, file, subjectId, findings); + } continue; } diff --git a/src/extract/serialize.ts b/src/extract/serialize.ts index c55fbc4e..5bdf6bb4 100644 --- a/src/extract/serialize.ts +++ b/src/extract/serialize.ts @@ -52,6 +52,7 @@ function canonicalOpenQuestion(question: IntentOpenQuestion): Record { ...(node.title === undefined ? {} : { title: node.title }), ...(node.framing === undefined ? {} : { framing: node.framing }), file: node.file, + members: [...node.members], ...(node.modelRefs === undefined ? {} : { modelRefs: node.modelRefs }), }; case "Anchor": diff --git a/src/graph/schema.ts b/src/graph/schema.ts index b78efbae..231d023f 100644 --- a/src/graph/schema.ts +++ b/src/graph/schema.ts @@ -3,7 +3,7 @@ import { codeAnchor } from "../model/code-anchor.js"; import type { SpecAltitude, SpecKind, SpecReadiness } from "../model/descriptors.js"; import type { SpecSections } from "../model/sections.js"; -export const schemaVersion = "0.6.0" as const; +export const schemaVersion = "0.7.0" as const; const schemaVersionAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.schema-version"), @@ -95,6 +95,8 @@ export interface PackNode extends GraphNodeBase { * Node data, not edges: the `spec:extraction.derive-graph` edge contract has no `modelRefs` edge type — the * pack-coherence check reads this (every entry resolves to a `model`-kind spec). */ + /** The manifest's `specs` list as authored, duplicates included; the `belongsTo` edges re-express it. */ + readonly members: readonly string[]; readonly modelRefs?: readonly string[]; readonly file: string; } diff --git a/src/ids.ts b/src/ids.ts index 7cdcfc00..625e9fd6 100644 --- a/src/ids.ts +++ b/src/ids.ts @@ -8,9 +8,19 @@ type Brand = string & { const LOWERCASE_NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/u; const PATH_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]*$/u; const ENTRY_ADDRESS_NAMESPACE = "spec"; -const ENTRY_ADDRESS_SECTIONS: readonly string[] = ["design", "ui"]; const ENTRY_ADDRESS_KEY_PATTERN = /^[a-z][A-Za-z0-9]*$/u; +/** + * The sections an entry address may name: a keyed entry of `design` or `ui`, or a keyed open + * question (`spec:model.open-question-keys`). + */ +export const ENTRY_ADDRESS_SECTIONS = ["design", "ui", "question"] as const; +export type EntryAddressSection = (typeof ENTRY_ADDRESS_SECTIONS)[number]; + +function isEntryAddressSection(value: string | undefined): value is EntryAddressSection { + return ENTRY_ADDRESS_SECTIONS.some((section) => section === value); +} + export type SpecId = Brand<"SpecId">; export type PackId = Brand<"PackId">; export type AnchorId = Brand<"AnchorId">; @@ -60,9 +70,9 @@ function validatePath(value: string, path: string): void { /** * The `#` sub-part is an entry address, `spec:#
.`, naming one keyed entry of a - * Spec's `design` or `ui` section. It is reserved in every namespace and never part of an - * identity: the parser admits it so prose can carry it, and every id slot refuses it. The refusal - * rows run in order and the first match wins. + * Spec's `design` or `ui` section or one keyed open question. It is reserved in every namespace + * and never part of an identity: the parser admits it so prose can carry it, and every id slot + * refuses it. The refusal rows run in order and the first match wins. */ function validateEntryAddress(value: string, namespace: string, subpath: string): void { if (namespace !== ENTRY_ADDRESS_NAMESPACE) { @@ -76,8 +86,8 @@ function validateEntryAddress(value: string, namespace: string, subpath: string) const dotIndex = subpath.indexOf("."); const section = dotIndex === -1 ? undefined : subpath.slice(0, dotIndex); - if (section === undefined || !ENTRY_ADDRESS_SECTIONS.includes(section)) { - failId(value, "entry address must be
. with section design or ui"); + if (!isEntryAddressSection(section)) { + failId(value, "entry address must be
. with section design, ui, or question"); } if (!ENTRY_ADDRESS_KEY_PATTERN.test(subpath.slice(dotIndex + 1))) { diff --git a/src/import/emit-markdown.ts b/src/import/emit-markdown.ts index 43426658..4a8b34d9 100644 --- a/src/import/emit-markdown.ts +++ b/src/import/emit-markdown.ts @@ -57,13 +57,19 @@ function intentSection( ...importTexts(intent?.risks).map((value) => `- risk: ${value}`), ...importTexts(intent?.assumptions).map((value) => `- assumption: ${value}`), ]; + // An omitted `blocking` flag is non-blocking, as the readiness floor reads it, so it emits the + // `[non-blocking]` marker; a flag that is present and not a boolean has no marker. const questions = Array.isArray(intent?.openQuestions) ? intent.openQuestions.flatMap((question) => { const entry = importData(question); const value = importText(entry?.question); - return value === undefined || typeof entry?.blocking !== "boolean" + const key = importText(entry?.key); + const blocking = entry?.blocking; + return value === undefined || (blocking !== undefined && typeof blocking !== "boolean") ? [] - : [`- [${entry.blocking ? "blocking" : "non-blocking"}] ${value}`]; + : [ + `- [${blocking === true ? "blocking" : "non-blocking"}${key === undefined ? "" : ` #${key}`}] ${value}`, + ]; }) : []; const fieldBlock = [...fields, ...repeated].join("\n"); diff --git a/src/import/markdown-fidelity.ts b/src/import/markdown-fidelity.ts index 7f95c196..b936f056 100644 --- a/src/import/markdown-fidelity.ts +++ b/src/import/markdown-fidelity.ts @@ -36,6 +36,27 @@ function normalizeRelations(data: Record): Record): Record { + const intent = data.intent; + if (!isRecord(intent) || !isUnknownArray(intent.openQuestions)) return data; + return { + ...data, + intent: { + ...intent, + openQuestions: intent.openQuestions.map((question) => + isRecord(question) && question.blocking === undefined + ? { ...question, blocking: false } + : question, + ), + }, + }; +} + function firstDivergentPath(left: unknown, right: unknown, path = ""): string | undefined { if (isDeepStrictEqual(left, right)) { return undefined; @@ -80,8 +101,8 @@ export function assertMarkdownEmissionFidelity(reified: ReifiedSpec, document: s } const divergent = firstDivergentPath( - normalizeRelations(reified.data), - normalizeRelations(emitted.data), + normalizeOpenQuestions(normalizeRelations(reified.data)), + normalizeOpenQuestions(normalizeRelations(emitted.data)), ); if (divergent !== undefined) { diff --git a/src/model/sections.ts b/src/model/sections.ts index 9de897ec..4a0bd6c5 100644 --- a/src/model/sections.ts +++ b/src/model/sections.ts @@ -28,7 +28,7 @@ export type SpecSectionContent = Record; /** An open question is prose, or an object flaggable `blocking` (MD-9: blocks `defined`+). */ export type IntentOpenQuestion = | string - | { readonly question: string; readonly blocking?: boolean }; + | { readonly question: string; readonly blocking?: boolean; readonly key?: string }; export interface IntentSection { readonly description?: string; diff --git a/src/projections/design-review-markdown.ts b/src/projections/design-review-markdown.ts index daa1279d..f71cc38b 100644 --- a/src/projections/design-review-markdown.ts +++ b/src/projections/design-review-markdown.ts @@ -84,7 +84,7 @@ export function renderTableInlineCode(value: string): string { return renderInlineCode(value.replaceAll("|", "\\|").replaceAll(/\s+/gu, " ").trim()); } -function renderDynamicValue(value: unknown): unknown { +export function renderDynamicValue(value: unknown): unknown { const record = asRecord(value); if (record !== undefined) { diff --git a/src/projections/design-review-section-content.ts b/src/projections/design-review-section-content.ts index 69f6bf86..369f7f03 100644 --- a/src/projections/design-review-section-content.ts +++ b/src/projections/design-review-section-content.ts @@ -1,6 +1,13 @@ import { renderStepText } from "../notation/slots.js"; import { escapeRenderedField, sectionDescription } from "./owned-prose.js"; -import { asArray, asRecord, asText, tableCell, textEntries } from "./design-review-markdown.js"; +import { + asArray, + asRecord, + asText, + renderInlineCode, + tableCell, + textEntries, +} from "./design-review-markdown.js"; export function renderIntent(intent: Record): readonly string[] { const description = sectionDescription(intent); @@ -43,7 +50,11 @@ export function renderIntent(intent: Record): readonly string[] const structured = asRecord(entry); const question = asText(structured?.question) ?? "(malformed open-question entry)"; const blocking = structured?.blocking === true ? " — **blocking**" : ""; - lines.push(`- ${escapeRenderedField(question)}${blocking}`); + // A keyed question opens with its key, so the page shows the part of its entry address the + // rest of the page does not (`spec:decisions.question-key-rendering`). + const key = asText(structured?.key); + const opening = key === undefined ? "" : `${renderInlineCode(`#${key}`)} `; + lines.push(`- ${opening}${escapeRenderedField(question)}${blocking}`); } } diff --git a/src/projections/design-review-sections.ts b/src/projections/design-review-sections.ts index bb0bb373..a8f9207e 100644 --- a/src/projections/design-review-sections.ts +++ b/src/projections/design-review-sections.ts @@ -5,7 +5,7 @@ import { asRecord, asText, renderInlineCode, - renderDynamicRecord, + renderDynamicValue, textEntries, } from "./design-review-markdown.js"; import { @@ -79,14 +79,12 @@ function renderVerification(verification: Record): readonly str return lines.length === 1 ? [] : lines; } -/** The open bags (`design` / `ui`, L9): dynamic keys canonicalized, rendered as data. */ +/** Open sections render entries in authored order, with structured values nested under their items. */ function renderOpenBag(name: string, content: Record): readonly string[] { const description = sectionDescription(content); - const data = renderDynamicRecord( - Object.fromEntries(Object.entries(content).filter(([key]) => key !== "description")), - ); + const entries = Object.entries(content).filter(([key]) => key !== "description"); - if (Object.keys(data).length === 0 && description.length === 0) { + if (entries.length === 0 && description.length === 0) { return []; } @@ -96,8 +94,32 @@ function renderOpenBag(name: string, content: Record): readonly lines.push("", ...description); } - if (Object.keys(data).length > 0) { - lines.push("", "```json", ...JSON.stringify(data, null, 2).split("\n"), "```"); + if (entries.length > 0) { + lines.push(""); + } + + for (const [key, value] of entries) { + const item = `- ${renderInlineCode(key)}:`; + + if (typeof value === "string") { + const [first = "", ...rest] = value.split("\n"); + lines.push( + first.length === 0 ? item : `${item} ${escapeRenderedField(first)}`, + ...rest.map((line) => ` ${escapeRenderedField(line)}`), + ); + } else if (asArray(value) !== undefined || asRecord(value) !== undefined) { + lines.push( + item, + "", + " ```json", + ...JSON.stringify(renderDynamicValue(value), null, 2) + .split("\n") + .map((line) => ` ${line}`), + " ```", + ); + } else { + lines.push(`${item} ${renderInlineCode(JSON.stringify(value))}`); + } } return lines; diff --git a/src/reader/reader.ts b/src/reader/reader.ts index 897a9e17..8c67008f 100644 --- a/src/reader/reader.ts +++ b/src/reader/reader.ts @@ -17,7 +17,7 @@ import type { PackNode, PrimitiveNode, } from "../graph/schema.js"; -import { SPEC_KIND_DISPLAY_LABELS } from "../model/descriptors.js"; +import { SPEC_KIND_DISPLAY_LABELS, SPEC_READINESS } from "../model/descriptors.js"; import type { SpecAltitude, SpecKind, SpecReadiness } from "../model/descriptors.js"; import type { SpecSections } from "../model/sections.js"; import type { Finding } from "../validate/contracts.js"; @@ -109,6 +109,12 @@ export interface OracleBinding { export interface SpecContext extends SpecSummary { readonly sections?: SpecSections; readonly floorFailures: readonly ReadinessFloorFailure[]; + /** + * The unmet clauses of the rung above derived readiness (`idea` when no rung derives), by the + * same evaluator; empty when derived readiness is `ready`. A report, never a promotion + * (`spec:validation.next-rung-floor`). + */ + readonly nextRungFailures: readonly ReadinessFloorFailure[]; /** The spec's authored relations (declared edges of authored types; `belongsTo` rides `packs`). */ readonly relationsOut: readonly RelationEnd[]; /** Authored-type edges pointing at the spec — who refines / depends on / verifies it. */ @@ -545,10 +551,17 @@ export function createReader(graph: GraphSchema): Reader { return { anchorId: oracleEdge.from, claim: oracleEdge.claim, ...location }; })(); + const summary = summarize(node); + const nextRung = + summary.derivedReadiness === undefined + ? SPEC_READINESS[0] + : SPEC_READINESS[SPEC_READINESS.indexOf(summary.derivedReadiness) + 1]; + return { - ...summarize(node), + ...summary, ...(node.sections === undefined ? {} : { sections: node.sections }), floorFailures: evaluateReadinessFloor(node, index), + nextRungFailures: nextRung === undefined ? [] : evaluateReadinessFloor(node, index, nextRung), relationsOut, relationsIn, implementations, @@ -565,31 +578,28 @@ export function createReader(graph: GraphSchema): Reader { return undefined; } - const members = (index.edgesByTo.get(id) ?? []) - .filter((edge) => edge.type === "belongsTo") - .map((edge): PackMemberSummary => { - const member = index.primitivesById.get(edge.from); + const members = node.members.map((memberId): PackMemberSummary => { + const member = index.primitivesById.get(memberId); - if (member === undefined) { - return { id: edge.from, resolved: false, deliveryFacts: [] }; - } + if (member === undefined) { + return { id: memberId, resolved: false, deliveryFacts: [] }; + } - const summary = summarize(member); + const summary = summarize(member); - return { - id: summary.id, - resolved: true, - ...(summary.title === undefined ? {} : { title: summary.title }), - specKind: summary.specKind, - altitude: summary.altitude, - statedReadiness: summary.statedReadiness, - ...(summary.derivedReadiness === undefined - ? {} - : { derivedReadiness: summary.derivedReadiness }), - deliveryFacts: summary.deliveryFacts, - }; - }) - .sort((left, right) => compareCodeUnits(left.id, right.id)); + return { + id: summary.id, + resolved: true, + ...(summary.title === undefined ? {} : { title: summary.title }), + specKind: summary.specKind, + altitude: summary.altitude, + statedReadiness: summary.statedReadiness, + ...(summary.derivedReadiness === undefined + ? {} + : { derivedReadiness: summary.derivedReadiness }), + deliveryFacts: summary.deliveryFacts, + }; + }); const verifierGaps = members .filter((member) => member.resolved && !member.deliveryFacts.includes("has-verifier")) diff --git a/src/validate/readiness-floor.ts b/src/validate/readiness-floor.ts index 7bcf8930..f377f139 100644 --- a/src/validate/readiness-floor.ts +++ b/src/validate/readiness-floor.ts @@ -28,10 +28,23 @@ import type { GraphIndex } from "./graph-index.js"; export type ReadinessPredicate = (node: PrimitiveNode, index: GraphIndex) => boolean; +/** The four typed dependencies the `ready` floor's target clause reads, in their sort order. */ +export type TypedDependencyType = "refines" | "dependsOn" | "constrainedBy" | "decidedBy"; + +/** One typed-dependency target that breaks the `ready` floor's target clause. */ +export interface ReadinessFloorTarget { + readonly type: TypedDependencyType; + readonly id: string; + /** The target's stated rung; absent when the target resolves to a node that is not a Spec. */ + readonly statedReadiness?: SpecReadiness; +} + export interface ReadinessClause { readonly id: string; readonly description: string; readonly predicate: ReadinessPredicate; + /** Set only on `typed-dependency-targets-are-defined`; the predicate is `targets(...).length === 0`. */ + readonly targets?: (node: PrimitiveNode, index: GraphIndex) => readonly ReadinessFloorTarget[]; } export interface ReadinessFloor { @@ -267,6 +280,70 @@ function allRelationsResolve(node: PrimitiveNode, index: GraphIndex): boolean { const definedIndex = SPEC_READINESS.indexOf("defined"); +const typedDependencyTypes: readonly TypedDependencyType[] = [ + "refines", + "dependsOn", + "constrainedBy", + "decidedBy", +]; + +function typedDependencyTypeOf(type: string): TypedDependencyType | undefined { + return typedDependencyTypes.find((candidate) => candidate === type); +} + +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +/** + * The targets that break the `ready` floor's target clause: one per distinct `(type, id)` among the + * declared typed-dependency edges whose target resolves and is not a Spec stating at least + * `defined`. Reads stated readiness on resolving targets only; the relation-resolution clause owns + * missing targets. A resolving non-Primitive target breaks this bound and the edge's conformance + * contract, and carries no stated rung. Sorted by relation type in the declared order, then by id + * in code-unit order (`spec:validation.next-rung-floor`). + */ +function typedDependencyTargets( + node: PrimitiveNode, + index: GraphIndex, +): readonly ReadinessFloorTarget[] { + const seen = new Set(); + const targets: ReadinessFloorTarget[] = []; + + for (const edge of declaredRelationEdges(node, index)) { + const type = typedDependencyTypeOf(edge.type); + + if (type === undefined || !index.nodesById.has(edge.to)) { + continue; + } + + const target = index.primitivesById.get(edge.to); + + if (target !== undefined && SPEC_READINESS.indexOf(target.readiness) >= definedIndex) { + continue; + } + + const key = `${type} ${edge.to}`; + + if (seen.has(key)) { + continue; + } + + seen.add(key); + targets.push({ + type, + id: edge.to, + ...(target === undefined ? {} : { statedReadiness: target.readiness }), + }); + } + + return targets.sort( + (left, right) => + typedDependencyTypes.indexOf(left.type) - typedDependencyTypes.indexOf(right.type) || + compareCodeUnits(left.id, right.id), + ); +} + const typedDependencyFloorAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.typed-dependency-floor"), label: "checks stated readiness across all four typed dependencies", @@ -275,29 +352,8 @@ const typedDependencyFloorAnchor = codeAnchor({ }); void typedDependencyFloorAnchor; -/** - * Reads stated readiness on resolving targets only. The relation-resolution clause owns missing - * targets. A resolving non-Primitive target fails this bound and the edge's conformance contract. - */ function typedDependencyTargetsAreDefined(node: PrimitiveNode, index: GraphIndex): boolean { - return declaredRelationEdges(node, index).every((edge) => { - if ( - edge.type !== "dependsOn" && - edge.type !== "refines" && - edge.type !== "constrainedBy" && - edge.type !== "decidedBy" - ) { - return true; - } - - if (!index.nodesById.has(edge.to)) { - return true; - } - - const target = index.primitivesById.get(edge.to); - - return target !== undefined && SPEC_READINESS.indexOf(target.readiness) >= definedIndex; - }); + return typedDependencyTargets(node, index).length === 0; } /** @@ -496,6 +552,7 @@ export const readinessFloors = { description: "Every refines, dependsOn, constrainedBy, and decidedBy target states at least defined.", predicate: typedDependencyTargetsAreDefined, + targets: typedDependencyTargets, }, { id: "anchors-resolve", @@ -512,8 +569,13 @@ export type ReadinessClauseId = (typeof readinessFloors)[SpecReadiness]["clauses export interface ReadinessFloorFailure { readonly clauseId: ReadinessClauseId; readonly description: string; + /** Only on a `typed-dependency-targets-are-defined` failure, sorted by type (the order above), then id. */ + readonly targets?: readonly ReadinessFloorTarget[]; } +/** A clause row of the table, read with its literal id. */ +type ReadinessTableClause = ReadinessClause & { readonly id: ReadinessClauseId }; + const ratifiedKinds: ReadonlySet = new Set(SPEC_KINDS); const ratifiedReadiness: ReadonlySet = new Set(SPEC_READINESS); @@ -522,13 +584,11 @@ const ratifiedReadiness: ReadonlySet = new Set(SPEC_READINESS); * rung must hold. Evaluates a `Primitive` node against the indexed graph (one validation path, * MD-14). */ -export const readinessFloorAnchor = codeAnchor({ - id: codeAnchorId("impl:protocol.readiness-floor"), - label: "evaluates the stated readiness floor against the graph", - satisfies: ref("spec:validation.readiness-floor"), - component: componentAnchorId("component:protocol.validate"), -}); - +/** + * Without `rung`, evaluates up to the stated rung. Given `rung`, evaluates the cumulative clauses + * of every rung up to and including it, whatever the Spec states; the reader passes the rung above + * derived readiness to name the next rung's unmet clauses (`spec:validation.next-rung-floor`). + */ const verifierSemanticsAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.verifier-semantics"), label: "readiness clauses over direct verification bindings", @@ -538,28 +598,54 @@ const verifierSemanticsAnchor = codeAnchor({ void verifierSemanticsAnchor; +const nextRungFloorAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.next-rung-floor"), + label: "evaluates the floor for a target rung and names the targets that break a dependency", + satisfies: ref("spec:validation.next-rung-floor"), + component: componentAnchorId("component:protocol.validate"), +}); +void nextRungFloorAnchor; + +export const readinessFloorAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.readiness-floor"), + label: "evaluates the stated readiness floor against the graph", + satisfies: ref("spec:validation.readiness-floor"), + component: componentAnchorId("component:protocol.validate"), +}); + export function evaluateReadinessFloor( node: PrimitiveNode, index: GraphIndex, + rung?: SpecReadiness, ): readonly ReadinessFloorFailure[] { + const target: string = rung ?? node.readiness; + // Foreign graph data can carry unratified strings in the typed descriptor slots; those are the // descriptor conformance errors (`spec:validation.claim-separation` — validateGraph fails closed), and the - // evaluator stays total: over an unratified kind or readiness it evaluates no clauses rather + // evaluator stays total: over an unratified kind or rung it evaluates no clauses rather // than dereferencing the evidence table into a throw or guessing a rung. - if (!ratifiedKinds.has(node.specKind) || !ratifiedReadiness.has(node.readiness)) { + if (!ratifiedKinds.has(node.specKind) || !ratifiedReadiness.has(target)) { return []; } - const statedIndex = SPEC_READINESS.indexOf(node.readiness); + const targetIndex = SPEC_READINESS.findIndex((readiness) => readiness === target); const failures: ReadinessFloorFailure[] = []; - for (const readiness of SPEC_READINESS.slice(0, statedIndex + 1)) { - for (const clause of readinessFloors[readiness].clauses) { + for (const readiness of SPEC_READINESS.slice(0, targetIndex + 1)) { + const clauses: readonly ReadinessTableClause[] = readinessFloors[readiness].clauses; + + for (const clause of clauses) { if (clause.predicate(node, index)) { continue; } - failures.push({ clauseId: clause.id, description: clause.description }); + const targets = clause.targets?.(node, index); + + failures.push({ + clauseId: clause.id, + description: clause.description, + ...(targets === undefined ? {} : { targets }), + }); } } diff --git a/src/validate/validators.ts b/src/validate/validators.ts index 54bb3fdc..a4ce2723 100644 --- a/src/validate/validators.ts +++ b/src/validate/validators.ts @@ -880,31 +880,82 @@ function checkOracleLinkage(graph: GraphSchema, index: GraphIndex): readonly Fin /* ----- conformance/pack-coherence (`spec:validation.pack-coherence`; F4) ----- */ +/** + * A Pack's members list and its derived `belongsTo` edges are two readings of one manifest, and + * the reader answers from both (`packContext` reads the list; a Spec's `packs` and blast radius + * follow the edges), so they must agree: every listed member has one edge into the Pack and every + * edge comes from a listed member. Findings keep authored order, one per member. An edge whose + * source is absent or no Spec is referential integrity's or the edge contract's finding, never a + * second one here; a listed member no edge carries has no other owner, so its kind is named here. + */ function checkPackMembers(pack: PackNode, index: GraphIndex, findings: Finding[]): void { - const memberCounts = new Map(); + const edgeCounts = new Map(); + const listCounts = new Map(); for (const edge of index.edgesByTo.get(pack.id) ?? []) { if (edge.type === "belongsTo") { - memberCounts.set(edge.from, (memberCounts.get(edge.from) ?? 0) + 1); + edgeCounts.set(edge.from, (edgeCounts.get(edge.from) ?? 0) + 1); } } - for (const [memberId, count] of memberCounts) { - if (count < 2) { - continue; - } + for (const memberId of pack.members) { + listCounts.set(memberId, (listCounts.get(memberId) ?? 0) + 1); + } + const report = (memberId: string, message: string): void => { findings.push( createFinding({ validatorId: graphValidatorIds.packCoherence, family: "conformance", severity: "error", - message: `Pack "${pack.id}" lists member "${memberId}" ${String(count)} times — membership is single-sourced on the manifest and duplicates are ambiguous (L2).`, + message, subjectId: pack.id, relatedId: memberId, file: pack.file, }), ); + }; + + for (const [memberId, listed] of listCounts) { + const edges = edgeCounts.get(memberId) ?? 0; + + if (edges === 0) { + const node = index.nodesById.get(memberId); + + report( + memberId, + node !== undefined && node.nodeType !== "Primitive" + ? `Pack "${pack.id}" lists member "${memberId}", which is a ${node.nodeType} node, not a Spec — Pack members are Specs.` + : `Pack "${pack.id}" lists member "${memberId}" with no belongsTo edge into the pack — the manifest and the derived edges must agree, or the reader answers membership differently per query.`, + ); + continue; + } + + const count = Math.max(listed, edges); + + if (count >= 2) { + report( + memberId, + `Pack "${pack.id}" lists member "${memberId}" ${String(count)} times — membership is single-sourced on the manifest and duplicates are ambiguous (L2).`, + ); + } + } + + for (const sourceId of edgeCounts.keys()) { + if (listCounts.has(sourceId)) { + continue; + } + + // An unlisted source that is absent or no Spec is already referential integrity's or the + // edge contract's finding; naming it here would be the second finding the rule refuses. + if (index.nodesById.get(sourceId)?.nodeType !== "Primitive") { + continue; + } + + report( + sourceId, + `Pack "${pack.id}" has a belongsTo edge from "${sourceId}", which its members do not list — the manifest and the derived edges must agree, or the reader answers membership differently per query.`, + ); } } @@ -943,7 +994,7 @@ function checkPackModelRefs(pack: PackNode, index: GraphIndex, findings: Finding const packCoherenceAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.pack-coherence"), - label: "checks Pack membership uniqueness and model-reference kinds", + label: "checks Pack membership agreement and uniqueness and model-reference kinds", satisfies: ref("spec:validation.pack-coherence"), component: componentAnchorId("component:protocol.validate"), }); @@ -1143,12 +1194,28 @@ function parseMention(token: string): IdParts | string { } } -/** An entry address resolves to an own key of its section, never the section's `description`. */ +/** + * A `design` or `ui` address resolves to an own key of its section, never the section's + * `description`. A `question` address resolves to an open question that carries the key, and + * there `description` is an ordinary key. + */ function hasAddressedEntry(target: PrimitiveNode, address: string): boolean { const dotIndex = address.indexOf("."); const key = address.slice(dotIndex + 1); + const sectionName = address.slice(0, dotIndex); const sections = target.sections as Readonly> | undefined; - const section = sections?.[address.slice(0, dotIndex)]; + + if (sectionName === "question") { + const intent = sections?.intent; + const questions = isRecord(intent) ? intent.openQuestions : undefined; + + return ( + Array.isArray(questions) && + questions.some((question) => isRecord(question) && question.key === key) + ); + } + + const section = sections?.[sectionName]; return key !== "description" && isRecord(section) && Object.hasOwn(section, key); } diff --git a/test/builders.typecheck.ts b/test/builders.typecheck.ts index da7c799a..c981df5a 100644 --- a/test/builders.typecheck.ts +++ b/test/builders.typecheck.ts @@ -17,6 +17,9 @@ const authoredSpec = spec({ kind: "behavior", altitude: "feature", readiness: "idea", + intent: { + openQuestions: [{ question: "Is checkout settled?", blocking: false, key: "checkoutSettled" }], + }, }); const authoredPack = pack({ diff --git a/test/checkout-v1.test.ts b/test/checkout-v1.test.ts index 714c099c..d04fc0af 100644 --- a/test/checkout-v1.test.ts +++ b/test/checkout-v1.test.ts @@ -61,6 +61,29 @@ describe("checkout-v1 tracer bullet (extractor-fed)", () => { ).toBe(expected); }); + it("carries the checkout manifest order through serialized members and reader context", () => { + const members = [ + "spec:orders.order-management", + "spec:orders.order-placement-flow", + "spec:orders.create-order", + "spec:orders.create-order.valid-cart", + "spec:orders.create-order.invalid-cart", + "spec:orders.create-order.api-contract", + "spec:orders.order-total-rule", + "spec:orders.order-inventory-rule", + "spec:orders.order-latency-constraint", + "spec:orders.order-model", + "spec:decisions.order-lifecycle", + ]; + const pack = extraction.graph.nodes.find((node) => node.nodeType === "Pack"); + expect(pack?.members).toEqual(members); + expect( + createReader(extraction.graph) + .packContext("pack:checkout-v1") + ?.members.map((member) => member.id), + ).toEqual(members); + }); + it("keeps every authored reference target inside the checkout-v1 example graph", () => { const nodeIds = new Set(extraction.graph.nodes.map((node) => node.id)); const relationTypes = new Set(authoredEdgeTypes); diff --git a/test/design-review-question-keys.test.ts b/test/design-review-question-keys.test.ts new file mode 100644 index 00000000..ba3e3cd2 --- /dev/null +++ b/test/design-review-question-keys.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; + +import { + createReader, + deriveGraph, + reifyMarkdownCarrier, + reifyTypeScriptCarrier, + renderDesignReview, +} from "../src/index.js"; +import type { ReifiedSpec } from "../src/index.js"; + +// The rendering `spec:decisions.question-key-rendering` decides; a decision record needs no binding. +const SPEC_ID = "spec:orders.question-keys"; + +function markdownSpec(questions: readonly string[]): readonly ReifiedSpec[] { + const result = reifyMarkdownCarrier( + `--- +id: ${SPEC_ID} +kind: rule +altitude: story +readiness: idea +relations: {} +--- +# Keyed questions + +## Intent +- outcome: Show each question's key. + +### Open questions +${questions.join("\n")} + +## Rule +- The probe states one rule. +`, + "question-keys.sdp.md", + ); + + expect(result.findings).toEqual([]); + return result.specs; +} + +/** The rendered open-question list of the one Spec page, without its heading. */ +function renderedQuestions(specs: readonly ReifiedSpec[]): string | undefined { + const pages = renderDesignReview(createReader(deriveGraph(specs, [], []))); + const page = pages.find((entry) => entry.path === "spec/orders.question-keys.md")?.content; + + return page?.split("### Open questions\n\n")[1]?.split("\n\n")[0]; +} + +describe("the Design Review shows an open question's key", () => { + it("opens a keyed question with its key as inline code and leaves an unkeyed one as before", () => { + const specs = markdownSpec([ + "- [blocking #aggregateReach] Does the owner widen the aggregate?", + "- [non-blocking] Is the name final?", + "- [non-blocking #pageHome] Where does the page live?", + ]); + + expect(renderedQuestions(specs)).toBe( + [ + "- `#aggregateReach` Does the owner widen the aggregate? — **blocking**", + "- Is the name final?", + "- `#pageHome` Where does the page live?", + ].join("\n"), + ); + }); + + it("escapes the question's text after the key as it escapes an unkeyed question", () => { + const specs = markdownSpec([ + "- [blocking #pipeKey] Does a | # stay *literal* & _plain_?", + "- [blocking] Does a | # stay *literal* & _plain_?", + ]); + const [keyed, unkeyed] = renderedQuestions(specs)?.split("\n") ?? []; + + expect(unkeyed).toBe("- Does a \\| \\# stay *literal* & _plain_? — **blocking**"); + expect(keyed).toBe(`- \`#pipeKey\` ${unkeyed?.slice("- ".length) ?? ""}`); + }); + + it("shows description as an ordinary key", () => { + const specs = markdownSpec(["- [non-blocking #description] Is description a key?"]); + + expect(renderedQuestions(specs)).toBe("- `#description` Is description a key?"); + }); + + it("renders a TypeScript question whose key the carrier dropped as an unkeyed one", () => { + const reified = reifyTypeScriptCarrier( + `import { spec, specId } from "@libar-dev/software-delivery-protocol"; +export const carrier = spec({ + id: specId("${SPEC_ID}"), + kind: "rule", + altitude: "story", + readiness: "idea", + intent: { + outcome: "Show each question's key.", + openQuestions: [ + { question: "Kept?", blocking: true, key: "keptKey" }, + { question: "Dropped?", blocking: true, key: "Dropped" }, + "A prose question.", + ], + }, +});`, + "question-keys.sdp.ts", + ); + + expect(reified.findings.map((finding) => finding.path)).toEqual([ + "intent.openQuestions[1].key", + ]); + expect(renderedQuestions(reified.specs)).toBe( + [ + "- `#keptKey` Kept? — **blocking**", + "- Dropped? — **blocking**", + "- A prose question.", + ].join("\n"), + ); + }); +}); diff --git a/test/design-review-review-08.test.ts b/test/design-review-review-08.test.ts index df04667a..a869b284 100644 --- a/test/design-review-review-08.test.ts +++ b/test/design-review-review-08.test.ts @@ -20,31 +20,75 @@ describe("review-08 Design Review rendering", () => { expect(renderInlineCode(" padded ")).toBe("` padded `"); }); - it("preserves fenced JSON data in authored key order", () => { + it.each(["design", "ui"] as const)("renders %s values as authored list bytes", (section) => { const graph = deriveFixtureGraph({ specs: [ spec({ id: specId("spec:orders.literal-json"), - title: "Literal JSON", + title: "Literal values", kind: "behavior", altitude: "story", readiness: "idea", - design: { - "a|x": "Review & safely.", - ab: "Keep `code` literal.", + [section]: { + description: "Section prose.\nSecond paragraph.", + zeta: "plain text", + alpha: "first line\nsecond `line` | & #\n\nlast line", + empty: "", + number: 3, + boolean: true, + null: null, + array: ["Review & safely.", { z: true, a: null }], + object: { z: "Keep `code` literal.", a: [1, false] }, + "`a|x`": "Keep `code` | # > & \\ *stars* [link](url) _text_", }, }), ], }); const page = pageByPath(renderDesignReview(createReader(graph)), "spec/orders.literal-json.md"); - const fenced = /```json\n([\s\S]*?)\n```/u.exec(page)?.[1]; + const rendered = page + .split(`## ${section === "design" ? "Design" : "Ui"}\n\n`)[1] + ?.split("\n\n## ")[0]; - expect(fenced).toBeDefined(); - expect(JSON.parse(fenced ?? "{}")).toEqual({ - ab: "Keep `code` literal.", - "a|x": "Review & safely.", - }); - expect(fenced?.indexOf('"a|x"')).toBeLessThan(fenced?.indexOf('"ab"') ?? -1); + expect(rendered).toBe( + [ + "Section prose.", + "Second paragraph.", + "", + "- `zeta`: plain text", + "- `alpha`: first line", + " second \\`line\\` \\| <tag> & \\#", + " ", + " last line", + "- `empty`:", + "- `number`: `3`", + "- `boolean`: `true`", + "- `null`: `null`", + "- `array`:", + "", + " ```json", + " [", + ' "Review & safely.",', + " {", + ' "z": true,', + ' "a": null', + " }", + " ]", + " ```", + "- `object`:", + "", + " ```json", + " {", + ' "z": "Keep `code` literal.",', + ' "a": [', + " 1,", + " false", + " ]", + " }", + " ```", + "- `` `a|x` ``: Keep \\`code\\` \\| \\# <tag> > & \\\\ *stars* [link](url) _text_", + ].join("\n"), + ); + expect(rendered).not.toContain("description"); }); it("preserves literal finding locations inside delimiter-safe table code spans", () => { diff --git a/test/design-review.test.ts b/test/design-review.test.ts index 8d6296ce..fe8b0c32 100644 --- a/test/design-review.test.ts +++ b/test/design-review.test.ts @@ -95,7 +95,7 @@ describe("the Design Review — the one generated read-only view", () => { "## Narrative\n\nThe Protocol's own delivery model exercises the same carrier, graph, checks, and projections offered to consumers.\n\n**Readiness:", ); expect(page).toContain("## Intent\n\n- **outcome:"); - expect(index).toContain("schema `0.6.0`"); + expect(index).toContain("schema `0.7.0`"); expect(pack).toContain( "| [`spec:protocol.self-hosting`](../spec/protocol.self-hosting.md) The Protocol authors and validates itself | behavior | epic | defined | ready | none | none |", ); @@ -154,10 +154,10 @@ describe("the Design Review — the one generated read-only view", () => { expect(page).toContain("## Intent\n\nEscaped \\`code\\` \\| \\# <tag> > quote"); expect(page).toContain("## Behavior\n\nBehavior description.\n\n### Rules"); expect(page).toContain("## Domain vocabulary\n\nModel description.\n\n| Term | Definition |"); - expect(page).toContain("## Design\n\nDesign description.\n\n```json"); + expect(page).toContain("## Design\n\nDesign description.\n\n- `layout`: linear"); expect(page).toContain("## Decision\n\nDecision description.\n\n**Context."); expect(page).toContain("## Verification intent\n\nVerification description.\n\n- **mode:"); - expect(page).toContain("## Ui\n\nUI description.\n\n```json"); + expect(page).toContain("## Ui\n\nUI description.\n\n- `surface`: review"); expect(page).not.toContain('"description"'); expect(withoutProse).not.toContain("## Narrative"); expect(withoutProse).not.toContain("\n\n\n\n"); @@ -267,7 +267,7 @@ describe("the Design Review — the one generated read-only view", () => { } }); - it("renders authored entry order in fenced JSON and the Model table", () => { + it("renders authored entry order in lists and the Model table", () => { const graph = deriveFixtureGraph({ specs: [ spec({ @@ -289,12 +289,15 @@ describe("the Design Review — the one generated read-only view", () => { ], }); const page = pageByPath(renderDesignReview(createReader(graph)), "spec/probe.order.md"); - const fences = [...page.matchAll(/```json\n([\s\S]*?)\n```/gu)]; - expect(fences).toHaveLength(2); - for (const fence of fences) { - const record = JSON.parse(fence[1] ?? "{}") as Record; - expect(Object.keys(record)).toEqual(["zeta", "alpha", "mid10", "mid2"]); + for (const [heading, description] of [ + ["Design", "Design prose."], + ["Ui", "UI prose."], + ] as const) { + expect(page).toContain( + `## ${heading}\n\n${description}\n\n- \`zeta\`: z\n- \`alpha\`: a\n- \`mid10\`: ten\n- \`mid2\`: two\n`, + ); } + expect(page).not.toContain("```json"); const terms = page.split("\n").filter((line) => /^\| (?:zebra|apple) \|/u.test(line)); expect(terms).toEqual(["| zebra | z |", "| apple | a |"]); }); @@ -357,7 +360,7 @@ describe("the Design Review — the one generated read-only view", () => { expect(permutedPages).toEqual(firstPages); }); - it("escapes Markdown text fields while preserving fenced JSON data", () => { + it("escapes Markdown text fields and open-section strings", () => { const graph = deriveFixtureGraph({ specs: [ spec({ @@ -384,7 +387,7 @@ describe("the Design Review — the one generated read-only view", () => { expect(page).toContain("- **outcome:** Keep <outcome> & visible."); expect(page).toContain("- Keep <rule> & stable."); expect(page).toContain("| <Term> & | Define <term> & safely. |"); - expect(page).toContain('"surface": "Review & safely."'); + expect(page).toContain("- `surface`: Review <design> & safely."); expect(page).toContain("- Inspect <criterion> & output."); }); diff --git a/test/extract.test.ts b/test/extract.test.ts index ada21de9..4d2bfb23 100644 --- a/test/extract.test.ts +++ b/test/extract.test.ts @@ -8,6 +8,7 @@ import { afterAll, describe, expect, it } from "vitest"; import { ref, specTest, testAnchorId } from "@libar-dev/software-delivery-protocol"; import { + createReader, deriveGraph, extract, extractFindingIds, @@ -1914,3 +1915,286 @@ describe("Gherkin carrier suffix discovery", () => { expect(result.graph.nodes).toEqual([]); }); }); + +describe("open question keys in the TypeScript carrier", () => { + function questionsCarrier(questions: string): string { + return `import { spec, specId } from "@libar-dev/software-delivery-protocol"; +export const carrier = spec({ + id: specId("spec:orders.question-keys"), + kind: "rule", + altitude: "story", + readiness: "idea", + intent: { + outcome: "Key the questions.", + openQuestions: [ +${questions} + ], + }, +});`; + } + + function refusal(index: number, reason: string, line: number) { + const path = `intent.openQuestions[${String(index)}].key`; + return { + validatorId: extractFindingIds.unrecognizedProperty, + family: "conformance", + severity: "error", + message: `property "${path}" is refused: ${reason}`, + subjectId: "spec:orders.question-keys", + path, + file: "question-keys.sdp.ts", + line, + }; + } + + // The first question object sits at line 10, one per line after it. + it("admits a lawful key beside prose and unkeyed questions", () => { + const reified = reifyTypeScriptCarrier( + questionsCarrier( + [ + ` { question: "Does the owner widen the aggregate?", blocking: true, key: "aggregateReach" },`, + ` "A prose question.",`, + ` { question: "Is the name final?" },`, + ` { question: "Is description ordinary?", key: "description" },`, + ].join("\n"), + ), + "question-keys.sdp.ts", + ); + + expect(reified.findings).toEqual([]); + expect(reified.specs[0]?.data.intent).toEqual({ + outcome: "Key the questions.", + openQuestions: [ + { question: "Does the owner widen the aggregate?", blocking: true, key: "aggregateReach" }, + "A prose question.", + { question: "Is the name final?" }, + { question: "Is description ordinary?", key: "description" }, + ], + }); + }); + + it("drops a key off the grammar with an error and keeps the question and the Spec", () => { + const reified = reifyTypeScriptCarrier( + questionsCarrier( + [ + ` { question: "Upper?", blocking: true, key: "AggregateReach" },`, + ` { question: "Hyphen?", key: "aggregate-reach" },`, + ` { question: "Number?", key: 7 },`, + ` { question: "Empty?", key: "" },`, + ` { question: "Lawful?", key: "pageHome" },`, + ].join("\n"), + ), + "question-keys.sdp.ts", + ); + + const reason = "open question keys must be lower-camel ASCII"; + expect(reified.findings).toEqual([ + refusal(0, reason, 10), + refusal(1, reason, 11), + refusal(2, reason, 12), + refusal(3, reason, 13), + ]); + expect(reified.specs).toHaveLength(1); + expect(reified.specs[0]?.data.intent).toEqual({ + outcome: "Key the questions.", + openQuestions: [ + { question: "Upper?", blocking: true }, + { question: "Hyphen?" }, + { question: "Number?" }, + { question: "Empty?" }, + { question: "Lawful?", key: "pageHome" }, + ], + }); + }); + + it("drops a key an earlier question carries, keeping the first", () => { + const reified = reifyTypeScriptCarrier( + questionsCarrier( + [ + ` { question: "First?", blocking: true, key: "aggregateReach" },`, + ` { question: "Bad first?", key: "Bad" },`, + ` { question: "Second?", key: "aggregateReach" },`, + ` { question: "Third?", key: "Bad" },`, + ].join("\n"), + ), + "question-keys.sdp.ts", + ); + + expect(reified.findings).toEqual([ + refusal(1, "open question keys must be lower-camel ASCII", 11), + refusal(2, "open question keys must be unique", 12), + refusal(3, "open question keys must be lower-camel ASCII", 13), + ]); + expect(reified.specs).toHaveLength(1); + expect(reified.specs[0]?.data.intent).toMatchObject({ + openQuestions: [ + { question: "First?", blocking: true, key: "aggregateReach" }, + { question: "Bad first?" }, + { question: "Second?" }, + { question: "Third?" }, + ], + }); + }); + + it("derives a graph where only the first of a repeated key remains", () => { + const reified = reifyTypeScriptCarrier( + questionsCarrier( + [ + ` { question: "First?", key: "aggregateReach" },`, + ` { question: "Second?", key: "aggregateReach" },`, + ].join("\n"), + ), + "question-keys.sdp.ts", + ); + const graph = deriveGraph(reified.specs, [], []); + const node = graph.nodes.find((candidate) => candidate.id === "spec:orders.question-keys"); + + expect((node as PrimitiveNode | undefined)?.sections?.intent?.openQuestions).toEqual([ + { question: "First?", key: "aggregateReach" }, + { question: "Second?" }, + ]); + }); + + // A key that is not static drops alone, like a static key off the grammar: the blocking sibling + // survives, so readiness never derives past the question. The Markdown carrier parses a key from + // the marker regex, so a non-static key exists only in the TypeScript carrier. + describe("a non-static key drops alone and the blocking sibling holds readiness", () => { + const subjectId = "spec:orders.question-keys"; + + // The subject clears every `ready` clause but the blocking question: a resolving `refines` + // target at `defined`, its rule statement, and no anchors. + function readinessCarrier(questions: string, blockingSibling = true): string { + const sibling = blockingSibling + ? ` { question: "Does the owner widen the aggregate?", blocking: true, key: "aggregateReach" },\n` + : ""; + + return `import { ref, refines, spec, specId } from "@libar-dev/software-delivery-protocol"; +const someIdentifier = "aggregateReach"; +export const parent = spec({ + id: specId("spec:orders.question-parent"), + title: "The parent", + kind: "rule", + altitude: "feature", + readiness: "defined", + intent: { outcome: "Hold the parent." }, + behavior: { rules: ["The parent states one rule."] }, +}); +export const carrier = spec({ + id: specId("${subjectId}"), + title: "Keyed questions", + kind: "rule", + altitude: "story", + readiness: "ready", + relations: [refines(ref("spec:orders.question-parent"))], + intent: { + outcome: "Key the questions.", + openQuestions: [ +${sibling}${questions} + ], + }, + behavior: { rules: ["The subject states one rule."] }, +});`; + } + + // The question after the blocking sibling sits at line 23. + const questionLine = 23; + + function reifyReadiness(questions: string) { + const reified = reifyTypeScriptCarrier(questions, "question-keys.sdp.ts"); + const derived = createReader(deriveGraph(reified.specs, [], [])).specContext(subjectId); + + return { + errors: reified.findings.filter((finding) => finding.severity === "error"), + validatorIds: new Set(reified.findings.map((finding) => finding.validatorId)), + openQuestions: reified.specs.find((entry) => entry.id === subjectId)?.data.intent, + derivedReadiness: derived?.derivedReadiness, + }; + } + + it("derives ready when no question blocks — the control for every case below", () => { + const result = reifyReadiness( + readinessCarrier(` { question: "Is the name final?", key: "nameFinal" },`, false), + ); + + expect(result.errors).toEqual([]); + expect(result.derivedReadiness).toBe("ready"); + }); + + it.each([ + ["null", "key: null"], + ["undefined", "key: undefined"], + ["a function", 'key: () => "x"'], + ["an identifier", "key: someIdentifier"], + ["a template with a substitution", "key: `${someIdentifier}`"], + ])("strips a key that is %s and keeps the question and its blocking sibling", (_, key) => { + const result = reifyReadiness( + readinessCarrier(` { question: "Is the name final?", ${key} },`), + ); + + expect(result.errors).toEqual([ + refusal(1, "open question keys must be lower-camel ASCII", questionLine), + ]); + expect(result.validatorIds.has(extractFindingIds.nonStaticSection)).toBe(false); + expect(result.openQuestions).toEqual({ + outcome: "Key the questions.", + openQuestions: [ + { + question: "Does the owner widen the aggregate?", + blocking: true, + key: "aggregateReach", + }, + { question: "Is the name final?" }, + ], + }); + expect(result.derivedReadiness).toBe("scoped"); + }); + + it("drops a repeated key alone and keeps the blocking sibling", () => { + const result = reifyReadiness( + readinessCarrier(` { question: "Is the name final?", key: "aggregateReach" },`), + ); + + expect(result.errors).toEqual([ + refusal(1, "open question keys must be unique", questionLine), + ]); + expect(result.openQuestions).toMatchObject({ + openQuestions: [ + { + question: "Does the owner widen the aggregate?", + blocking: true, + key: "aggregateReach", + }, + { question: "Is the name final?" }, + ], + }); + expect(result.derivedReadiness).toBe("scoped"); + }); + + it("admits an omitted key beside the blocking sibling", () => { + const result = reifyReadiness(readinessCarrier(` { question: "Is the name final?" },`)); + + expect(result.errors).toEqual([]); + expect(result.openQuestions).toMatchObject({ + openQuestions: [ + { + question: "Does the owner widen the aggregate?", + blocking: true, + key: "aggregateReach", + }, + { question: "Is the name final?" }, + ], + }); + expect(result.derivedReadiness).toBe("scoped"); + }); + + it("keeps every other property strict: a non-static question still drops the array", () => { + const result = reifyReadiness( + readinessCarrier(` { question: someIdentifier, key: "nameFinal" },`), + ); + + expect(result.errors).toEqual([]); + expect(result.validatorIds.has(extractFindingIds.nonStaticSection)).toBe(true); + expect(result.openQuestions).toEqual({ outcome: "Key the questions." }); + }); + }); +}); diff --git a/test/extraction.pack-member-order.manifest-order-kept.test.generated.ts b/test/extraction.pack-member-order.manifest-order-kept.test.generated.ts new file mode 100644 index 00000000..f447957e --- /dev/null +++ b/test/extraction.pack-member-order.manifest-order-kept.test.generated.ts @@ -0,0 +1,74 @@ +// GENERATED by `sdp build` — the runnable registrar of spec:extraction.pack-member-order.manifest-order-kept. +// 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 { manifestOrderKeptContract } from "../generated/contracts/extraction.pack-member-order.manifest-order-kept.contract.js"; +import type { + Step, + StepParams, +} from "../generated/contracts/extraction.pack-member-order.manifest-order-kept.contract.js"; +import type { + PackMemberOrderConditions, + PackMemberOrderOutcome, +} from "../generated/contracts/extraction.pack-member-order.space.js"; + +export function registerManifestOrderKept( + adapters: RunnableExampleAdapters, +): void { + type Execution = RunnableExampleWorld; + const bindings = { + "a Markdown Pack manifest whose specs list reads {manifestOrder}": () => { + // Given state is materialized by createWorld from the generated point. + }, + "the graph is derived and serialized": async (execution) => { + await invokeRunnableExample(execution); + }, + "the serialized Pack node lists the members {serializedMembers}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the serialized Pack node lists the members {serializedMembers}", + params, + }); + }, + "the serialized belongsTo edges run from {edgeSources}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the serialized belongsTo edges run from {edgeSources}", + params, + }); + }, + "the reader's Pack context lists the members {readerMembers}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the reader's Pack context lists the members {readerMembers}", + params, + }); + }, + "the payload declares the schema version {schemaVersion}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the payload declares the schema version {schemaVersion}", + params, + }); + }, + } satisfies StepBindings; + + registerRunnableExample( + manifestOrderKeptContract, + { manifestOrder: "spec:probe.zeta, spec:probe.alpha, spec:probe.mid" }, + ["manifestOrder"], + adapters, + bindings, + ); +} diff --git a/test/fixtures/checkout-v1/expected-census/index.md b/test/fixtures/checkout-v1/expected-census/index.md index 550cce1e..346dfc0a 100644 --- a/test/fixtures/checkout-v1/expected-census/index.md +++ b/test/fixtures/checkout-v1/expected-census/index.md @@ -1,6 +1,6 @@ # Census -A disposable projection of the one graph (schema `0.6.0`): 17 nodes · 32 edges · 11 Specs. +A disposable projection of the one graph (schema `0.7.0`): 17 nodes · 32 edges · 11 Specs. ## Spec kinds diff --git a/test/fixtures/checkout-v1/expected-design-review/index.md b/test/fixtures/checkout-v1/expected-design-review/index.md index 2ca1f9ad..8952599d 100644 --- a/test/fixtures/checkout-v1/expected-design-review/index.md +++ b/test/fixtures/checkout-v1/expected-design-review/index.md @@ -1,6 +1,6 @@ # Design Review -The one generated read-only view — a pure projection of the one graph (`graph.json`, schema `0.6.0`): 17 nodes · 32 edges. +The one generated read-only view — a pure projection of the one graph (`graph.json`, schema `0.7.0`): 17 nodes · 32 edges. ## Specs diff --git a/test/fixtures/checkout-v1/expected-design-review/pack/checkout-v1.md b/test/fixtures/checkout-v1/expected-design-review/pack/checkout-v1.md index e480e76e..0cddc672 100644 --- a/test/fixtures/checkout-v1/expected-design-review/pack/checkout-v1.md +++ b/test/fixtures/checkout-v1/expected-design-review/pack/checkout-v1.md @@ -8,17 +8,17 @@ | Spec | Kind | Altitude | Stated | Floor reached | Implementation binding | Verifier binding | |---|---|---|---|---|---|---| -| [`spec:decisions.order-lifecycle`](../spec/decisions.order-lifecycle.md) Order lifecycle keeps validation before creation | decision | feature | defined | ready | none | none | +| [`spec:orders.order-management`](../spec/orders.order-management.md) Order management | behavior | epic | defined | ready | none | none | +| [`spec:orders.order-placement-flow`](../spec/orders.order-placement-flow.md) Order placement flow | workflow | feature | scoped | scoped | none | none | | [`spec:orders.create-order`](../spec/orders.create-order.md) Customer creates an order | behavior | feature | defined | ready | present | present | -| [`spec:orders.create-order.api-contract`](../spec/orders.create-order.api-contract.md) Create-order API contract | contract | story | idea | idea | none | none | -| [`spec:orders.create-order.invalid-cart`](../spec/orders.create-order.invalid-cart.md) Invalid cart is rejected | example | story | defined | ready | none | none | | [`spec:orders.create-order.valid-cart`](../spec/orders.create-order.valid-cart.md) Valid cart creates an order | example | story | ready | ready | none | present | +| [`spec:orders.create-order.invalid-cart`](../spec/orders.create-order.invalid-cart.md) Invalid cart is rejected | example | story | defined | ready | none | none | +| [`spec:orders.create-order.api-contract`](../spec/orders.create-order.api-contract.md) Create-order API contract | contract | story | idea | idea | none | none | +| [`spec:orders.order-total-rule`](../spec/orders.order-total-rule.md) Order total matches cart math | rule | story | defined | ready | present | none | | [`spec:orders.order-inventory-rule`](../spec/orders.order-inventory-rule.md) Order creation requires available inventory | rule | story | defined | ready | none | none | | [`spec:orders.order-latency-constraint`](../spec/orders.order-latency-constraint.md) Create-order latency stays within checkout budget | constraint | story | defined | ready | none | none | -| [`spec:orders.order-management`](../spec/orders.order-management.md) Order management | behavior | epic | defined | ready | none | none | | [`spec:orders.order-model`](../spec/orders.order-model.md) Order-management domain vocabulary | model | story | defined | ready | none | none | -| [`spec:orders.order-placement-flow`](../spec/orders.order-placement-flow.md) Order placement flow | workflow | feature | scoped | scoped | none | none | -| [`spec:orders.order-total-rule`](../spec/orders.order-total-rule.md) Order total matches cart math | rule | story | defined | ready | present | none | +| [`spec:decisions.order-lifecycle`](../spec/decisions.order-lifecycle.md) Order lifecycle keeps validation before creation | decision | feature | defined | ready | none | none | **Vocabulary (`modelRefs`):** [`spec:orders.order-model`](../spec/orders.order-model.md) — Order-management domain vocabulary @@ -26,15 +26,15 @@ Members with no verifier binding — a surfaced absence, informative, never a gate. `ready` members are the priority slice (designed, stated done, unverified): -- [`spec:decisions.order-lifecycle`](../spec/decisions.order-lifecycle.md) — Order lifecycle keeps validation before creation (stated `defined`) -- [`spec:orders.create-order.api-contract`](../spec/orders.create-order.api-contract.md) — Create-order API contract (stated `idea`) +- [`spec:orders.order-management`](../spec/orders.order-management.md) — Order management (stated `defined`) +- [`spec:orders.order-placement-flow`](../spec/orders.order-placement-flow.md) — Order placement flow (stated `scoped`) - [`spec:orders.create-order.invalid-cart`](../spec/orders.create-order.invalid-cart.md) — Invalid cart is rejected (stated `defined`) +- [`spec:orders.create-order.api-contract`](../spec/orders.create-order.api-contract.md) — Create-order API contract (stated `idea`) +- [`spec:orders.order-total-rule`](../spec/orders.order-total-rule.md) — Order total matches cart math (stated `defined`) - [`spec:orders.order-inventory-rule`](../spec/orders.order-inventory-rule.md) — Order creation requires available inventory (stated `defined`) - [`spec:orders.order-latency-constraint`](../spec/orders.order-latency-constraint.md) — Create-order latency stays within checkout budget (stated `defined`) -- [`spec:orders.order-management`](../spec/orders.order-management.md) — Order management (stated `defined`) - [`spec:orders.order-model`](../spec/orders.order-model.md) — Order-management domain vocabulary (stated `defined`) -- [`spec:orders.order-placement-flow`](../spec/orders.order-placement-flow.md) — Order placement flow (stated `scoped`) -- [`spec:orders.order-total-rule`](../spec/orders.order-total-rule.md) — Order total matches cart math (stated `defined`) +- [`spec:decisions.order-lifecycle`](../spec/decisions.order-lifecycle.md) — Order lifecycle keeps validation before creation (stated `defined`) ## Findings diff --git a/test/fixtures/checkout-v1/expected-graph.json b/test/fixtures/checkout-v1/expected-graph.json index 794e8e90..7f384840 100644 --- a/test/fixtures/checkout-v1/expected-graph.json +++ b/test/fixtures/checkout-v1/expected-graph.json @@ -1,5 +1,5 @@ { - "schemaVersion": "0.6.0", + "schemaVersion": "0.7.0", "nodes": [ { "id": "api:orders.post", @@ -40,6 +40,19 @@ "title": "Checkout v1", "framing": "Let customers create orders from valid carts with honest authored traceability.", "file": "specs/checkout.pack.sdp.md", + "members": [ + "spec:orders.order-management", + "spec:orders.order-placement-flow", + "spec:orders.create-order", + "spec:orders.create-order.valid-cart", + "spec:orders.create-order.invalid-cart", + "spec:orders.create-order.api-contract", + "spec:orders.order-total-rule", + "spec:orders.order-inventory-rule", + "spec:orders.order-latency-constraint", + "spec:orders.order-model", + "spec:decisions.order-lifecycle" + ], "modelRefs": [ "spec:orders.order-model" ] diff --git a/test/gherkin-view.test.ts b/test/gherkin-view.test.ts index fdcaa06e..b62095bf 100644 --- a/test/gherkin-view.test.ts +++ b/test/gherkin-view.test.ts @@ -55,6 +55,7 @@ function specContext(overrides: Partial = {}): SpecContext { return { ...summary, floorFailures: [], + nextRungFailures: [], relationsOut: [], relationsIn: [], implementations: [], diff --git a/test/graph-schema.test.ts b/test/graph-schema.test.ts index 44dc1b77..40196811 100644 --- a/test/graph-schema.test.ts +++ b/test/graph-schema.test.ts @@ -30,7 +30,7 @@ const openSectionOrderTestAnchor = specTest({ void openSectionOrderTestAnchor; describe("graph schema", () => { - it("serializes authored entry order with description first and schema 0.6.0", () => { + it("serializes authored entry order with description first and schema 0.7.0", () => { const graph = deriveFixtureGraph({ specs: [ spec({ @@ -53,7 +53,7 @@ describe("graph schema", () => { }); const serialized = JSON.parse(serializeGraph(graph)) as GraphSchema; const node = serialized.nodes.find((entry) => entry.nodeType === "Primitive"); - expect(serialized.schemaVersion).toBe("0.6.0"); + expect(serialized.schemaVersion).toBe("0.7.0"); expect(Object.keys(node?.sections?.design ?? {})).toEqual([ "description", "zeta", @@ -139,10 +139,10 @@ The Model prose. const pages = renderDesignReview(reader); const page = (path: string) => pages.find((entry) => entry.path === path)?.content ?? ""; expect(page("spec/probe.order.md")).toContain( - '## Design\n\nDesign prose.\n\n```json\n{\n "zeta": "z",\n "alpha": "a"\n}\n```', + "## Design\n\nDesign prose.\n\n- `zeta`: z\n- `alpha`: a", ); expect(page("spec/probe.order.md")).toContain( - '## Ui\n\nUI prose.\n\n```json\n{\n "zeta": "z",\n "alpha": "a"\n}\n```', + "## Ui\n\nUI prose.\n\n- `zeta`: z\n- `alpha`: a", ); expect(page("spec/probe.terms.md")).toContain( "The Model prose.\n\n| Term | Definition |\n|---|---|\n| zeta | The last letter. |\n| description | An ordinary term. |\n| alpha | The first letter. |", @@ -152,8 +152,52 @@ The Model prose. } }); + it("serializes question text, optional blocking and optional key in canonical order", () => { + const graph = deriveFixtureGraph({ + specs: [ + spec({ + id: specId("spec:probe.questions"), + title: "Question serialization", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { + openQuestions: [ + { key: "settled", blocking: false, question: "Is it settled?" }, + { key: "named", question: "Is it named?" }, + { blocking: true, question: "Is it blocked?" }, + { question: "Is it open?" }, + "A string question", + ], + }, + }), + ], + }); + const payload = JSON.parse(serializeGraph(graph)) as GraphSchema; + const node = payload.nodes.find( + (entry): entry is PrimitiveNode => entry.nodeType === "Primitive", + ); + const questions = node?.sections?.intent?.openQuestions ?? []; + expect(questions).toEqual([ + { question: "Is it settled?", blocking: false, key: "settled" }, + { question: "Is it named?", key: "named" }, + { question: "Is it blocked?", blocking: true }, + { question: "Is it open?" }, + "A string question", + ]); + expect( + questions.map((question) => (typeof question === "string" ? [] : Object.keys(question))), + ).toEqual([ + ["question", "blocking", "key"], + ["question", "key"], + ["question", "blocking"], + ["question"], + [], + ]); + }); + it("exports the graph schema contracts", () => { - expect(schemaVersion).toBe("0.6.0"); + expect(schemaVersion).toBe("0.7.0"); expect(graphNodeTypes).toEqual(["Primitive", "Pack", "Anchor", "CodeNode"]); expect(deliveryFactNames).toEqual(["implemented", "has-verifier", "observed"]); expect(derivedEdgeTypes).toEqual(["belongsTo", "satisfies", "models", "memberOf", "uses"]); diff --git a/test/ids.test.ts b/test/ids.test.ts index d70788c5..7ef03154 100644 --- a/test/ids.test.ts +++ b/test/ids.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + ENTRY_ADDRESS_SECTIONS, anchorId, codeAnchorId, componentAnchorId, @@ -15,6 +16,8 @@ import { const validIds = [ "spec:orders.create-order", "spec:orders.create-order#design.validCart", + "spec:orders.create-order#question.aggregateReach", + "spec:orders.create-order#question.description", "pack:checkout-v1", "impl:orders.create-order-use-case", "test:orders.create-order.valid-cart", @@ -39,12 +42,24 @@ const entryAddressRefusals = [ ["pack:x#", "the # sub-part is an entry address and is admitted only in the spec namespace"], ["spec:x#", "malformed # suffix"], ["spec:x#a#b", "malformed # suffix"], - ["spec:x#foo", "entry address must be
. with section design or ui"], - ["spec:x#model.foo", "entry address must be
. with section design or ui"], - ["spec:x#design", "entry address must be
. with section design or ui"], + ["spec:x#foo", "entry address must be
. with section design, ui, or question"], + [ + "spec:x#model.foo", + "entry address must be
. with section design, ui, or question", + ], + ["spec:x#design", "entry address must be
. with section design, ui, or question"], + ["spec:x#question", "entry address must be
. with section design, ui, or question"], + [ + "spec:x#questions.k", + "entry address must be
. with section design, ui, or question", + ], + [ + "spec:x#openQuestions.k", + "entry address must be
. with section design, ui, or question", + ], [ "spec:orders.create-order#valid-cart", - "entry address must be
. with section design or ui", + "entry address must be
. with section design, ui, or question", ], ["spec:x#design.1", "entry address key must be lower-camel ASCII"], ["spec:x#design.Foo", "entry address key must be lower-camel ASCII"], @@ -53,6 +68,9 @@ const entryAddressRefusals = [ ["spec:x#design.", "entry address key must be lower-camel ASCII"], ["spec:x#design.éclair", "entry address key must be lower-camel ASCII"], ["spec:x#design.café", "entry address key must be lower-camel ASCII"], + ["spec:x#question.AggregateReach", "entry address key must be lower-camel ASCII"], + ["spec:x#question.aggregate-reach", "entry address key must be lower-camel ASCII"], + ["spec:x#question.", "entry address key must be lower-camel ASCII"], ] as const; /** The complete message a call throws; a substring match would let extra text through. */ @@ -113,6 +131,27 @@ describe("ids", () => { }); }); + it("names the three sections an entry address may carry", () => { + expect(ENTRY_ADDRESS_SECTIONS).toEqual(["design", "ui", "question"]); + }); + + it("parses a question address with its key verbatim", () => { + expect(parseId("spec:orders.create-order#question.aggregateReach")).toEqual({ + namespace: "spec", + path: "orders.create-order", + subpath: "question.aggregateReach", + }); + }); + + it("refuses a question address where a Spec id is required", () => { + expect(thrownMessage(() => ref("spec:x#question.k"))).toBe( + 'Invalid ID "spec:x#question.k": an entry address is not a Spec id', + ); + expect(thrownMessage(() => codeAnchorId("impl:x#question.k"))).toBe( + 'Invalid ID "impl:x#question.k": the # sub-part is an entry address and is admitted only in the spec namespace', + ); + }); + it("refuses an entry address where a Spec id is required", () => { expect(thrownMessage(() => ref("spec:x#design.k"))).toBe( 'Invalid ID "spec:x#design.k": an entry address is not a Spec id', diff --git a/test/import-emit-markdown.test.ts b/test/import-emit-markdown.test.ts index ee29d411..185b5398 100644 --- a/test/import-emit-markdown.test.ts +++ b/test/import-emit-markdown.test.ts @@ -132,6 +132,35 @@ relations: {} expect(emitted).toContain("### Open questions\n\n- [blocking] Is the section complete?"); }); + it("writes a keyed question's key after its flag and leaves an unkeyed marker as it was", () => { + const source = reifiedSpec({ + intent: { + outcome: "Each question keeps its key.", + openQuestions: [ + { + question: "Does the owner widen the aggregate?", + blocking: true, + key: "aggregateReach", + }, + { question: "Is the name final?", blocking: false }, + { question: "Where does the page live?", blocking: false, key: "pageHome" }, + ], + }, + }); + + const emitted = expectMarkdownRoundTrip(source); + + expect(emitted).toContain( + [ + "### Open questions", + "", + "- [blocking #aggregateReach] Does the owner widen the aggregate?", + "- [non-blocking] Is the name final?", + "- [non-blocking #pageHome] Where does the page live?", + ].join("\n"), + ); + }); + it.each([ [ "Behavior", diff --git a/test/import.test.ts b/test/import.test.ts index 1238b01d..d120bf93 100644 --- a/test/import.test.ts +++ b/test/import.test.ts @@ -212,6 +212,95 @@ export const authoredSpec = spec({ ); }); + it("imports a keyed TypeScript question into a keyed Markdown marker", () => { + // Given: a TypeScript Spec whose open questions carry a key and none. + const source = `import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const authoredSpec = spec({ + id: specId("spec:import.keyed"), + title: "Import a keyed question", + kind: "rule", + altitude: "story", + readiness: "idea", + intent: { + outcome: "Keep the key.", + openQuestions: [ + { question: "Does the owner widen the aggregate?", blocking: true, key: "aggregateReach" }, + { question: "Is the name final?", blocking: false }, + ], + }, +}); +`; + + // When + const result = importTypeScriptSpec(source, "specs/keyed.sdp.ts"); + + // Then: the key rides in the marker, and the unkeyed question keeps today's marker. + expect(result.findings).toEqual([]); + expect(result.emitted?.content).toContain( + "- [blocking #aggregateReach] Does the owner widen the aggregate?\n- [non-blocking] Is the name final?", + ); + }); + + it("imports a keyed question whose blocking flag is omitted as a non-blocking marker", () => { + // Given: the lawful `{ question, key }` shape, whose omitted flag means non-blocking. + const source = `import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const authoredSpec = spec({ + id: specId("spec:import.keyed-unflagged"), + title: "Import a keyed question with no flag", + kind: "rule", + altitude: "story", + readiness: "idea", + intent: { + outcome: "Keep the key without a flag.", + openQuestions: [{ question: "Where does the page live?", key: "pageHome" }], + }, +}); +`; + + // When + const result = importTypeScriptSpec(source, "specs/keyed-unflagged.sdp.ts"); + + // Then: the fidelity check passes and the marker carries the non-blocking flag and the key. + expect(result.findings).toEqual([]); + expect(result.emitted?.content).toContain( + "### Open questions\n\n- [non-blocking #pageHome] Where does the page live?", + ); + }); + + it("refuses an import whose question key the reifier drops", () => { + // Given: a key off the grammar, which the TypeScript carrier drops with an error. + const source = `import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const authoredSpec = spec({ + id: specId("spec:import.bad-key"), + title: "Import a bad key", + kind: "rule", + altitude: "story", + readiness: "idea", + intent: { openQuestions: [{ question: "Is it keyed?", blocking: true, key: "Bad" }] }, +}); +`; + + // When + const result = importTypeScriptSpec(source, "specs/bad-key.sdp.ts"); + + // Then: nothing is emitted that would lose the authored key without a trace. + expect(result).not.toHaveProperty("emitted"); + expect(result.findings).toContainEqual( + expect.objectContaining({ + validatorId: "extract/unrecognized-property", + severity: "error", + message: + 'property "intent.openQuestions[0].key" is refused: open question keys must be lower-camel ASCII', + }), + ); + expect(result.findings).toContainEqual( + expect.objectContaining({ validatorId: importFindingIds.refusal }), + ); + }); + it("refuses a path that cannot produce a Markdown sibling", () => { // Given: valid TypeScript carrier text with a non-carrier path. const source = specSource("spec:import.invalid-path", "Reject an invalid source path"); diff --git a/test/markdown-reifier.test.ts b/test/markdown-reifier.test.ts index 8774bcac..cc5482be 100644 --- a/test/markdown-reifier.test.ts +++ b/test/markdown-reifier.test.ts @@ -1185,3 +1185,123 @@ export const invalid = spec({ id: specId("spec:carrier.invalid-prose"), title: " expect(result.findings[0]).toMatchObject({ validatorId, line }); }); }); + +describe("open question keys in the Markdown marker", () => { + function questionsCarrier(...questions: readonly string[]): string { + return carrierBody( + `# Title\n## Intent\n- outcome: Key the questions.\n\n### Open questions\n${questions.join("\n")}`, + ); + } + + // The carrier body opens at line 8; the first question sits at line 13. + const firstQuestionLine = 13; + + it("reads a key after the flag and leaves an unkeyed question without one", () => { + const result = reify( + questionsCarrier( + "- [blocking #aggregateReach] Does the owner widen the aggregate?", + "- [non-blocking] Is the name final?", + "- [non-blocking #description] Is description an ordinary key?", + ), + ); + + expect(result.findings).toEqual([]); + expect(result.specs[0]?.data.intent).toEqual({ + outcome: "Key the questions.", + openQuestions: [ + { question: "Does the owner widen the aggregate?", blocking: true, key: "aggregateReach" }, + { question: "Is the name final?", blocking: false }, + { question: "Is description an ordinary key?", blocking: false, key: "description" }, + ], + }); + expect(Object.hasOwn(asQuestions(result)[1] ?? {}, "key")).toBe(false); + }); + + it.each([ + ["an upper-case first letter", "AggregateReach"], + ["an empty key", ""], + ["a hyphen", "aggregate-reach"], + ["a digit first", "1st"], + ["a space inside", "aggregate reach"], + ["a non-ASCII letter", "café"], + ])("refuses the carrier whole on a key with %s", (_name, key) => { + const result = reify( + questionsCarrier("- [non-blocking] Is the name final?", `- [blocking #${key}] Text?`), + ); + + expect(result.specs).toEqual([]); + expect(result.findings).toEqual([ + expect.objectContaining({ + validatorId: "extract/invalid-markdown-structure", + severity: "error", + line: firstQuestionLine + 1, + message: "open question keys must be lower-camel ASCII", + }), + ]); + }); + + it("refuses the carrier whole on a key an earlier question of the Spec carries", () => { + const result = reify( + questionsCarrier( + "- [blocking #aggregateReach] Does the owner widen the aggregate?", + "- [non-blocking] Is the name final?", + "- [non-blocking #aggregateReach] Does the aggregate keep its name?", + ), + ); + + expect(result.specs).toEqual([]); + expect(result.findings).toEqual([ + expect.objectContaining({ + validatorId: "extract/invalid-markdown-structure", + severity: "error", + line: firstQuestionLine + 2, + message: "open question keys must be unique", + }), + ]); + }); + + it("keeps distinct keys that differ only in case", () => { + const result = reify( + questionsCarrier("- [blocking #pageHome] Where?", "- [non-blocking #pagehome] Which?"), + ); + + expect(result.findings).toEqual([]); + expect(asQuestions(result).map((question) => question.key)).toEqual(["pageHome", "pagehome"]); + }); + + it.each([ + ["no space before the hash", "- [blocking#aggregateReach] Text?"], + ["two spaces before the hash", "- [blocking #aggregateReach] Text?"], + ["a key without its hash", "- [blocking aggregateReach] Text?"], + ])("keeps today's refusal for a marker with %s", (_name, question) => { + const result = reify(questionsCarrier(question)); + + expect(result.specs).toEqual([]); + expect(result.findings).toEqual([ + expect.objectContaining({ + validatorId: "extract/invalid-markdown-structure", + line: firstQuestionLine, + message: "Intent fields must precede ### Open questions", + }), + ]); + }); + + it("refuses a keyed question outside its H3 owner as an unkeyed one is refused", () => { + const result = reify( + carrierBody("# Title\n## Intent\n- [blocking #aggregateReach] Must be owned."), + ); + + expect(result.specs).toEqual([]); + expect(result.findings[0]).toMatchObject({ + validatorId: "extract/invalid-markdown-structure", + message: "open questions require ### Open questions", + }); + }); + + function asQuestions(result: ReturnType): readonly { readonly key?: unknown }[] { + const intent = result.specs[0]?.data.intent as + | { readonly openQuestions?: readonly { readonly key?: unknown }[] } + | undefined; + return intent?.openQuestions ?? []; + } +}); diff --git a/test/mermaid-render.test.ts b/test/mermaid-render.test.ts index 216c53cb..b4076136 100644 --- a/test/mermaid-render.test.ts +++ b/test/mermaid-render.test.ts @@ -47,7 +47,7 @@ function primitive(id: string, title = id): PrimitiveNode { } function pack(id: string, title = id): GraphNode { - return { id, nodeType: "Pack", claim: "declared", title, file: `${id}.pack.sdp.md` }; + return { id, nodeType: "Pack", claim: "declared", title, members: [], file: `${id}.pack.sdp.md` }; } function rendered(input: GraphSchema): ReadonlyMap { diff --git a/test/model.open-question-keys.keyed-question-resolves.test.generated.ts b/test/model.open-question-keys.keyed-question-resolves.test.generated.ts new file mode 100644 index 00000000..bea49c69 --- /dev/null +++ b/test/model.open-question-keys.keyed-question-resolves.test.generated.ts @@ -0,0 +1,82 @@ +// GENERATED by `sdp build` — the runnable registrar of spec:model.open-question-keys.keyed-question-resolves. +// 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 { keyedQuestionResolvesContract } from "../generated/contracts/model.open-question-keys.keyed-question-resolves.contract.js"; +import type { + Step, + StepParams, +} from "../generated/contracts/model.open-question-keys.keyed-question-resolves.contract.js"; +import type { + OpenQuestionKeysConditions, + OpenQuestionKeysOutcome, +} from "../generated/contracts/model.open-question-keys.space.js"; + +export function registerKeyedQuestionResolves( + adapters: RunnableExampleAdapters, +): void { + type Execution = RunnableExampleWorld; + const bindings = { + "a Markdown Spec carrier {specId} whose open questions read {questions}": () => { + // Given state is materialized by createWorld from the generated point. + }, + "a second Spec that names {address} in its narrative and declares dependsOn on the first": + () => { + // Given state is materialized by createWorld from the generated point. + }, + "the extractor reifies both carriers and the graph is validated": async (execution) => { + await invokeRunnableExample(execution); + }, + "the first carrier is reified: {reified}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the first carrier is reified: {reified}", + params, + }); + }, + "the extraction findings name {extractMessage}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the extraction findings name {extractMessage}", + params, + }); + }, + "the graph holds the question keys {keys}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the graph holds the question keys {keys}", + params, + }); + }, + "the report holds {mentionErrors} prose-mention errors": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the report holds {mentionErrors} prose-mention errors", + params, + }); + }, + } satisfies StepBindings; + + registerRunnableExample( + keyedQuestionResolvesContract, + { + specId: "spec:probe.subject", + questions: "- [blocking #aggregateReach] Does the owner widen the aggregate?; - [non-blocking] Is the name final?", + address: "spec:probe.subject#question.aggregateReach", + }, + ["specId", "questions", "address"], + adapters, + bindings, + ); +} diff --git a/test/model.open-question-keys.renamed-key-breaks.test.generated.ts b/test/model.open-question-keys.renamed-key-breaks.test.generated.ts new file mode 100644 index 00000000..03984726 --- /dev/null +++ b/test/model.open-question-keys.renamed-key-breaks.test.generated.ts @@ -0,0 +1,82 @@ +// GENERATED by `sdp build` — the runnable registrar of spec:model.open-question-keys.renamed-key-breaks. +// 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 { renamedKeyBreaksContract } from "../generated/contracts/model.open-question-keys.renamed-key-breaks.contract.js"; +import type { + Step, + StepParams, +} from "../generated/contracts/model.open-question-keys.renamed-key-breaks.contract.js"; +import type { + OpenQuestionKeysConditions, + OpenQuestionKeysOutcome, +} from "../generated/contracts/model.open-question-keys.space.js"; + +export function registerRenamedKeyBreaks( + adapters: RunnableExampleAdapters, +): void { + type Execution = RunnableExampleWorld; + const bindings = { + "a Markdown Spec carrier {specId} whose open questions read {questions}": () => { + // Given state is materialized by createWorld from the generated point. + }, + "a second Spec that names {address} in its narrative and declares dependsOn on the first": + () => { + // Given state is materialized by createWorld from the generated point. + }, + "the extractor reifies both carriers and the graph is validated": async (execution) => { + await invokeRunnableExample(execution); + }, + "the first carrier is reified: {reified}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the first carrier is reified: {reified}", + params, + }); + }, + "the extraction findings name {extractMessage}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the extraction findings name {extractMessage}", + params, + }); + }, + "the graph holds the question keys {keys}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the graph holds the question keys {keys}", + params, + }); + }, + "the report holds {mentionErrors} prose-mention errors": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the report holds {mentionErrors} prose-mention errors", + params, + }); + }, + } satisfies StepBindings; + + registerRunnableExample( + renamedKeyBreaksContract, + { + specId: "spec:probe.subject", + questions: "- [blocking #aggregateScope] Does the owner widen the aggregate?", + address: "spec:probe.subject#question.aggregateReach", + }, + ["specId", "questions", "address"], + adapters, + bindings, + ); +} diff --git a/test/model.open-question-keys.repeated-key-refused.test.generated.ts b/test/model.open-question-keys.repeated-key-refused.test.generated.ts new file mode 100644 index 00000000..d6574bbb --- /dev/null +++ b/test/model.open-question-keys.repeated-key-refused.test.generated.ts @@ -0,0 +1,82 @@ +// GENERATED by `sdp build` — the runnable registrar of spec:model.open-question-keys.repeated-key-refused. +// 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 { repeatedKeyRefusedContract } from "../generated/contracts/model.open-question-keys.repeated-key-refused.contract.js"; +import type { + Step, + StepParams, +} from "../generated/contracts/model.open-question-keys.repeated-key-refused.contract.js"; +import type { + OpenQuestionKeysConditions, + OpenQuestionKeysOutcome, +} from "../generated/contracts/model.open-question-keys.space.js"; + +export function registerRepeatedKeyRefused( + adapters: RunnableExampleAdapters, +): void { + type Execution = RunnableExampleWorld; + const bindings = { + "a Markdown Spec carrier {specId} whose open questions read {questions}": () => { + // Given state is materialized by createWorld from the generated point. + }, + "a second Spec that names {address} in its narrative and declares dependsOn on the first": + () => { + // Given state is materialized by createWorld from the generated point. + }, + "the extractor reifies both carriers and the graph is validated": async (execution) => { + await invokeRunnableExample(execution); + }, + "the first carrier is reified: {reified}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the first carrier is reified: {reified}", + params, + }); + }, + "the extraction findings name {extractMessage}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the extraction findings name {extractMessage}", + params, + }); + }, + "the graph holds the question keys {keys}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the graph holds the question keys {keys}", + params, + }); + }, + "the report holds {mentionErrors} prose-mention errors": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the report holds {mentionErrors} prose-mention errors", + params, + }); + }, + } satisfies StepBindings; + + registerRunnableExample( + repeatedKeyRefusedContract, + { + specId: "spec:probe.subject", + questions: "- [blocking #aggregateReach] Does the owner widen the aggregate?; - [non-blocking #aggregateReach] Does the aggregate keep its name?", + address: "spec:probe.subject#question.aggregateReach", + }, + ["specId", "questions", "address"], + adapters, + bindings, + ); +} diff --git a/test/package-smoke.test.ts b/test/package-smoke.test.ts index ae5b62b6..d0c324e5 100644 --- a/test/package-smoke.test.ts +++ b/test/package-smoke.test.ts @@ -20,6 +20,7 @@ const importFixture = join(repositoryRoot, "test/fixtures/import/round-trip/beha const expectedRootExports = [ "CODE_ANCHOR_NAMESPACES", + "ENTRY_ADDRESS_SECTIONS", "MAX_MERMAID_EDGES_PER_DIAGRAM", "MAX_MERMAID_NODES_PER_DIAGRAM", "PROTOCOL_MODULE_SPECIFIER", diff --git a/test/reader.test.ts b/test/reader.test.ts index 9c1a680e..52763164 100644 --- a/test/reader.test.ts +++ b/test/reader.test.ts @@ -7,12 +7,14 @@ import { codeAnchor, codeAnchorId, createReader, + dependsOn, extract, graphValidatorIds, oracleAnchorId, pack, packId, refines, + schemaVersion, spec, specId, specOracle, @@ -454,6 +456,9 @@ describe("the reader — the thin typed loader behind the agent surface", () => ]); expect(context?.floorFailures).toEqual([]); + // The example derives `ready`: there is no next rung, so no next-rung failure. + expect(context?.derivedReadiness).toBe("ready"); + expect(context?.nextRungFailures).toEqual([]); expect(context?.findings).toEqual([]); }); @@ -587,6 +592,98 @@ describe("the reader — the thin typed loader behind the agent surface", () => }); }); + describe("nextRungFailures — the unmet clauses of the rung above the floor reached", () => { + const targetClause = "typed-dependency-targets-are-defined"; + const ruleSpec = ( + id: string, + readiness: "idea" | "scoped" | "defined" | "ready", + relations: Parameters[0]["relations"] = [], + ) => + spec({ + id: specId(id), + title: id, + kind: "rule", + altitude: "story", + readiness, + intent: { outcome: "Probe the next rung." }, + behavior: { rules: ["The probe states one rule."] }, + relations, + }); + const contextOf = (...specs: Parameters[0][]) => + createReader(deriveFixtureGraph({ specs })).specContext("spec:probe.subject"); + + it("names the scoped dependency of a Spec stating defined at the next rung, ready", () => { + const context = contextOf( + ruleSpec("spec:probe.subject", "defined", [dependsOn(specId("spec:probe.basis"))]), + ruleSpec("spec:probe.basis", "scoped", [refines(specId("spec:probe.subject"))]), + ); + + expect(context?.floorFailures).toEqual([]); + expect(context?.derivedReadiness).toBe("defined"); + expect(context?.nextRungFailures).toEqual([ + { + clauseId: targetClause, + description: + "Every refines, dependsOn, constrainedBy, and decidedBy target states at least defined.", + targets: [{ type: "dependsOn", id: "spec:probe.basis", statedReadiness: "scoped" }], + }, + ]); + }); + + it("has no next-rung failure when the same dependency states defined and ready derives", () => { + const context = contextOf( + ruleSpec("spec:probe.subject", "defined", [dependsOn(specId("spec:probe.basis"))]), + ruleSpec("spec:probe.basis", "defined", [refines(specId("spec:probe.subject"))]), + ); + + expect(context?.derivedReadiness).toBe("ready"); + expect(context?.nextRungFailures).toEqual([]); + }); + + it("reads the rung above the floor reached, not the rung above the stated one", () => { + // Stated `idea` with headroom: the rung above the stated one (`scoped`) clears, while the + // rung above the floor reached (`ready`) fails on the dependency. + const context = contextOf( + ruleSpec("spec:probe.subject", "idea", [dependsOn(specId("spec:probe.basis"))]), + ruleSpec("spec:probe.basis", "scoped", [refines(specId("spec:probe.subject"))]), + ); + + expect(context?.statedReadiness).toBe("idea"); + expect(context?.derivedReadiness).toBe("defined"); + expect(context?.nextRungFailures.map((failure) => failure.clauseId)).toEqual([targetClause]); + }); + + it("reads idea as the next rung when no rung derives, and nothing for an unratified kind", () => { + const node = { + id: "spec:probe.subject", + nodeType: "Primitive" as const, + claim: "declared" as const, + specKind: "rule" as const, + altitude: "story" as const, + readiness: "idea" as const, + file: "specs/probe.sdp.md", + }; + const untitled = createReader({ schemaVersion, nodes: [node], edges: [] }).specContext( + node.id, + ); + + expect(untitled?.derivedReadiness).toBeUndefined(); + expect(untitled?.nextRungFailures.map((failure) => failure.clauseId)).toEqual([ + "title", + "intent.outcome-or-parent-relation", + ]); + + const foreign = createReader({ + schemaVersion, + nodes: [{ ...node, specKind: "epic" } as unknown as typeof node], + edges: [], + }).specContext(node.id); + + expect(foreign?.derivedReadiness).toBeUndefined(); + expect(foreign?.nextRungFailures).toEqual([]); + }); + }); + describe("packContext — the pack reviewed as a unit (JS-E4, JS-G4)", () => { it("lists members with their decode and the verifier gaps, ready ones as the priority slice", () => { const context = exampleReader().packContext("pack:checkout-v1"); @@ -598,15 +695,15 @@ describe("the reader — the thin typed loader behind the agent surface", () => // specs (create-order and its valid-cart example — the one ready member, covered, so not // a gap) and no ready member among the gaps, so no priority. expect(context?.verifierGaps.map((gap) => gap.id)).toEqual([ - "spec:decisions.order-lifecycle", - "spec:orders.create-order.api-contract", + "spec:orders.order-management", + "spec:orders.order-placement-flow", "spec:orders.create-order.invalid-cart", + "spec:orders.create-order.api-contract", + "spec:orders.order-total-rule", "spec:orders.order-inventory-rule", "spec:orders.order-latency-constraint", - "spec:orders.order-management", "spec:orders.order-model", - "spec:orders.order-placement-flow", - "spec:orders.order-total-rule", + "spec:decisions.order-lifecycle", ]); expect(context?.verifierGaps.every((gap) => !gap.priority)).toBe(true); }); @@ -650,6 +747,67 @@ describe("the reader — the thin typed loader behind the agent surface", () => ]); }); + it("answers membership the same from the list and from the belongsTo edges, and the validator names a graph where they disagree", () => { + const probe = (leaf: string) => + spec({ + id: specId(`spec:probe.${leaf}`), + title: `Probe ${leaf}`, + kind: "rule", + altitude: "story", + readiness: "idea", + intent: { outcome: `Probe ${leaf}.` }, + }); + const packNodeId = "pack:probe.checkout"; + const graph = deriveFixtureGraph({ + specs: [probe("a"), probe("b"), probe("c")], + packs: [ + pack({ + id: packId(packNodeId), + title: "Probe aggregate", + specs: [specId("spec:probe.a"), specId("spec:probe.b")], + }), + ], + }); + const membership = (reader: Reader) => ({ + listed: reader.packContext(packNodeId)?.members.map((member) => member.id), + byEdges: reader + .specs() + .filter((summary) => summary.packs.includes(packNodeId)) + .map((summary) => summary.id), + atRisk: reader + .blastRadius(["specs/fixture.pack.sdp.ts"]) + .atRisk.map((item) => item.id) + .sort(), + coherence: reader + .findings() + .filter((finding) => finding.validatorId === graphValidatorIds.packCoherence) + .map((finding) => finding.relatedId), + }); + + expect(membership(createReader(graph))).toEqual({ + listed: ["spec:probe.a", "spec:probe.b"], + byEdges: ["spec:probe.a", "spec:probe.b"], + atRisk: ["spec:probe.a", "spec:probe.b"], + coherence: [], + }); + + // A supplied graph whose list names `b` while no edge carries it: the list-reading query + // and the edge-reading queries disagree, and the validator names the member. + const skewed: GraphSchema = { + ...graph, + edges: graph.edges.filter( + (edge) => !(edge.type === "belongsTo" && edge.from === "spec:probe.b"), + ), + }; + + expect(membership(createReader(skewed))).toEqual({ + listed: ["spec:probe.a", "spec:probe.b"], + byEdges: ["spec:probe.a"], + atRisk: ["spec:probe.a"], + coherence: ["spec:probe.b"], + }); + }); + it("returns undefined for a non-pack id", () => { expect(exampleReader().packContext("spec:orders.create-order")).toBeUndefined(); }); diff --git a/test/reader.typecheck.ts b/test/reader.typecheck.ts index 00feb04c..ba390ff2 100644 --- a/test/reader.typecheck.ts +++ b/test/reader.typecheck.ts @@ -5,6 +5,8 @@ import type { FileEntry, PackContext, OracleBinding, + ReadinessFloorFailure, + ReadinessFloorTarget, Reader, SpecContext, SpecSummary, @@ -37,6 +39,17 @@ const pluralOracleKeyRemoved: "oracles" extends keyof SpecContext ? false : true void [singularOracleKey, pluralOracleKeyRemoved]; +// The next rung's failures sit beside the stated rung's on the same context, always present, and +// a typed-dependency failure carries its targets as data. +const nextRungFailures: readonly ReadinessFloorFailure[] | undefined = + specContext?.nextRungFailures; +const nextRungTargets: readonly ReadinessFloorTarget[] | undefined = + specContext?.nextRungFailures[0]?.targets; +const nextRungKeyRequired: undefined extends SpecContext["nextRungFailures"] ? false : true = true; +const nextRungIsContextOnly: "nextRungFailures" extends keyof SpecSummary ? false : true = true; + +void [nextRungFailures, nextRungTargets, nextRungKeyRequired, nextRungIsContextOnly]; + // @ts-expect-error bySymbol is aspirational — it rides the exhaustive impact graph and is not // stubbed: a method that throws would fake the capability its absence honestly hides (`06` §3). const aspirational: keyof Reader = "bySymbol"; diff --git a/test/readiness.test.ts b/test/readiness.test.ts index 28393d66..09c55369 100644 --- a/test/readiness.test.ts +++ b/test/readiness.test.ts @@ -6,17 +6,29 @@ import { SPEC_KINDS, buildGraphIndex, constrainedBy, + dependsOn, deriveReadiness, evaluateReadinessFloor, kindEvidence, readinessFloors, refines, + schemaVersion, spec, specId, + validateGraph, validationSeverities, validatorFamilies, } from "../src/index.js"; -import type { GraphIndex, PrimitiveNode, ReadinessFloorFailure, Spec } from "../src/index.js"; +import type { + GraphEdge, + GraphIndex, + GraphNode, + PrimitiveNode, + ReadinessClause, + ReadinessFloorFailure, + Spec, + SpecReadiness, +} from "../src/index.js"; import { deriveFixtureGraph } from "./helpers/fixture-graph.js"; /** Indexes the graph derived from the given model and resolves the subject's Primitive node. */ @@ -583,3 +595,204 @@ describe("the concreteness law — an example is a bound point (the plan-12 rati expect(derivedReadinessFor(unbound.id, unbound)).toBe("scoped"); }); }); + +describe("the floor for a target rung and the targets a dependency failure names", () => { + const subjectId = specId("spec:probe.subject"); + const basisId = specId("spec:probe.basis"); + const subjectStating = (readiness: Spec["readiness"]): Spec => + spec({ + id: subjectId, + title: "Subject", + kind: "rule", + altitude: "story", + readiness, + intent: { outcome: "Rest on the basis." }, + behavior: { rules: ["The subject states one rule."] }, + relations: [dependsOn(basisId)], + }); + const basisStating = (readiness: Spec["readiness"]): Spec => + spec({ + id: basisId, + title: "Basis", + kind: "rule", + altitude: "story", + readiness, + intent: { outcome: "Carry the subject." }, + behavior: { rules: ["The basis states one rule."] }, + }); + const targetClause = "typed-dependency-targets-are-defined"; + + it("names the scoped dependency at a target rung of ready on a Spec stating defined", () => { + const { node, index } = indexedSubject(subjectId, [ + subjectStating("defined"), + basisStating("scoped"), + ]); + + // Stated rung: honest. Target rung `ready`: the dependency below `defined` is named. + expect(evaluateReadinessFloor(node, index)).toEqual([]); + expect(evaluateReadinessFloor(node, index, "ready")).toEqual([ + { + clauseId: targetClause, + description: readinessFloors.ready.clauses[1].description, + targets: [{ type: "dependsOn", id: basisId, statedReadiness: "scoped" }], + }, + ]); + }); + + it("finds nothing at ready when the same dependency states defined", () => { + const { node, index } = indexedSubject(subjectId, [ + subjectStating("defined"), + basisStating("defined"), + ]); + + expect(evaluateReadinessFloor(node, index, "ready")).toEqual([]); + }); + + it("evaluates the target rung, not the stated one, in both directions", () => { + const { node, index } = indexedSubject(subjectId, [ + subjectStating("ready"), + basisStating("scoped"), + ]); + + // Stated `ready` fails on the target; a lower target rung evaluates only the clauses up to it. + expect(evaluateReadinessFloor(node, index).map((failure) => failure.clauseId)).toEqual([ + targetClause, + ]); + for (const rung of ["idea", "scoped", "defined"] as const) { + expect(evaluateReadinessFloor(node, index, rung)).toEqual([]); + } + }); + + it("evaluates the cumulative clauses of every rung up to the target", () => { + const bare = spec({ + id: subjectId, + title: "Bare", + kind: "rule", + altitude: "story", + readiness: "idea", + intent: { + outcome: "Say only the outcome.", + openQuestions: [{ question: "Q?", blocking: true }], + }, + }); + const { node, index } = indexedSubject(subjectId, [bare]); + + expect( + evaluateReadinessFloor(node, index, "defined").map((failure) => failure.clauseId), + ).toEqual([ + "at-least-one-relation", + "kind-evidence-present", + "kind-evidence-complete", + "no-blocking-open-questions", + ]); + expect( + evaluateReadinessFloor(node, index, "scoped").map((failure) => failure.clauseId), + ).toEqual(["at-least-one-relation", "kind-evidence-present"]); + }); + + it("yields no failure for an unratified kind or an unratified target rung", () => { + const { node, index } = indexedSubject(subjectId, [ + subjectStating("ready"), + basisStating("scoped"), + ]); + const foreignKind = { ...node, specKind: "epic" } as unknown as PrimitiveNode; + const foreignStated = { ...node, readiness: "shipped" } as unknown as PrimitiveNode; + + expect(evaluateReadinessFloor(foreignKind, index, "ready")).toEqual([]); + expect(evaluateReadinessFloor(node, index, "shipped" as SpecReadiness)).toEqual([]); + // An unratified stated rung yields nothing on its own, but a named target rung is evaluated. + expect(evaluateReadinessFloor(foreignStated, index)).toEqual([]); + expect( + evaluateReadinessFloor(foreignStated, index, "ready").map((failure) => failure.clauseId), + ).toEqual([targetClause]); + }); + + it("names every distinct breaking target, sorted by relation type and then by id", () => { + const subject = "spec:probe.subject"; + const primitive = (id: string, readiness: SpecReadiness): PrimitiveNode => ({ + id, + nodeType: "Primitive", + claim: "declared", + specKind: "rule", + altitude: "story", + readiness, + title: id, + file: "specs/probe.sdp.md", + sections: { intent: { outcome: "Probe." }, behavior: { rules: ["Probe rule."] } }, + }); + const nodes: GraphNode[] = [ + primitive(subject, "defined"), + primitive("spec:probe.B", "idea"), + primitive("spec:probe.a", "scoped"), + primitive("spec:probe.c", "defined"), + primitive("spec:probe.d", "ready"), + primitive("spec:probe.e", "idea"), + { id: "impl:probe.code", nodeType: "CodeNode", claim: "anchored", file: "src/probe.ts" }, + ]; + const edge = (type: GraphEdge["type"], to: string): GraphEdge => ({ + from: subject, + to, + type, + claim: "declared", + }); + const edges: GraphEdge[] = [ + edge("decidedBy", "spec:probe.a"), + edge("decidedBy", "impl:probe.code"), + edge("constrainedBy", "spec:probe.a"), + edge("dependsOn", "spec:probe.a"), + edge("dependsOn", "spec:probe.B"), + edge("dependsOn", "spec:probe.a"), + edge("dependsOn", "spec:probe.c"), + edge("dependsOn", "spec:probe.missing"), + edge("refines", "spec:probe.d"), + edge("refines", "spec:probe.a"), + edge("verifies", "spec:probe.e"), + edge("supersedes", "spec:probe.e"), + { from: subject, to: "spec:probe.e", type: "dependsOn", claim: "inferred" }, + ]; + const index = buildGraphIndex({ schemaVersion, nodes, edges }); + const node = index.primitivesById.get(subject); + if (node === undefined) throw new Error("missing probe subject"); + + const failures = evaluateReadinessFloor(node, index, "ready"); + + expect(failures.map((failure) => failure.clauseId)).toEqual([ + "all-relations-resolve", + targetClause, + ]); + // Only the typed-dependency clause carries targets. + expect(Object.hasOwn(failures[0] ?? {}, "targets")).toBe(false); + // Missing targets stay the resolution clause's; `verifies`, `supersedes`, inferred edges and + // targets stating at least `defined` never appear; a repeated edge appears once; a non-Spec + // target carries no stated rung; `B` sorts before `a` in code-unit order. + expect(failures[1]?.targets).toEqual([ + { type: "refines", id: "spec:probe.a", statedReadiness: "scoped" }, + { type: "dependsOn", id: "spec:probe.B", statedReadiness: "idea" }, + { type: "dependsOn", id: "spec:probe.a", statedReadiness: "scoped" }, + { type: "constrainedBy", id: "spec:probe.a", statedReadiness: "scoped" }, + { type: "decidedBy", id: "impl:probe.code" }, + { type: "decidedBy", id: "spec:probe.a", statedReadiness: "scoped" }, + ]); + }); + + it("keeps the readiness-floor finding message free of targets", () => { + const graph = deriveFixtureGraph({ specs: [subjectStating("ready"), basisStating("scoped")] }); + const findings = validateGraph(graph).findings.filter( + (finding) => + finding.validatorId === "honesty/readiness-floor" && finding.subjectId === subjectId, + ); + + expect(findings.map((finding) => finding.message)).toEqual([ + `Spec "${subjectId}" states readiness "ready" but does not satisfy floor clause "${targetClause}": Every refines, dependsOn, constrainedBy, and decidedBy target states at least defined.`, + ]); + }); + + it("sets a targets reader on the typed-dependency clause alone", () => { + const withTargets = Object.values(readinessFloors) + .flatMap((floor): readonly ReadinessClause[] => floor.clauses) + .filter((clause) => "targets" in clause) + .map((clause) => clause.id); + + expect(withTargets).toEqual([targetClause]); + }); +}); diff --git a/test/readiness.typecheck.ts b/test/readiness.typecheck.ts index e53af20e..26ebe5c0 100644 --- a/test/readiness.typecheck.ts +++ b/test/readiness.typecheck.ts @@ -1,5 +1,21 @@ -import { authoredEdgeTypes, schemaVersion } from "../src/index.js"; -import type { Finding, GraphEdge, GraphSchema, ValidationReport, Validator } from "../src/index.js"; +import { + authoredEdgeTypes, + buildGraphIndex, + evaluateReadinessFloor, + readinessFloors, + schemaVersion, +} from "../src/index.js"; +import type { + Finding, + GraphEdge, + GraphSchema, + ReadinessClause, + ReadinessFloorFailure, + ReadinessFloorTarget, + TypedDependencyType, + ValidationReport, + Validator, +} from "../src/index.js"; const graph = { schemaVersion, @@ -73,3 +89,41 @@ const invalidFinding: Finding = { }; void invalidFinding; + +// The evaluator answers for a target rung an evaluation names; the rung is a ratified readiness. +const floorIndex = buildGraphIndex(graph); +const floorNode = floorIndex.primitivesById.get("spec:orders.create-order"); +const nextRungFailures: readonly ReadinessFloorFailure[] = + floorNode === undefined ? [] : evaluateReadinessFloor(floorNode, floorIndex, "ready"); +const statedRungFailures: readonly ReadinessFloorFailure[] = + floorNode === undefined ? [] : evaluateReadinessFloor(floorNode, floorIndex); + +// A failure's targets are optional, and each target names its relation type and id; the stated +// rung is optional because a target may resolve to a node that is not a Spec. +const failureTargets: readonly ReadinessFloorTarget[] | undefined = nextRungFailures[0]?.targets; +const nonSpecTarget: ReadinessFloorTarget = { type: "decidedBy", id: "impl:orders.code" }; +const specTarget: ReadinessFloorTarget = { + type: "dependsOn", + id: "spec:orders.basis", + statedReadiness: "scoped", +}; +const typedDependencyTypes: readonly TypedDependencyType[] = [ + "refines", + "dependsOn", + "constrainedBy", + "decidedBy", +]; +const clauseTargets: ReadinessClause["targets"] = readinessFloors.ready.clauses[1].targets; + +void [statedRungFailures, failureTargets, nonSpecTarget, specTarget, typedDependencyTypes]; +void clauseTargets; + +if (floorNode !== undefined) { + // @ts-expect-error a target rung is a ratified readiness, never an arbitrary string. + evaluateReadinessFloor(floorNode, floorIndex, "shipped"); +} + +// @ts-expect-error `verifies` and `supersedes` are not typed dependencies. +const verifiesTarget: ReadinessFloorTarget = { type: "verifies", id: "spec:orders.example" }; + +void verifiesTarget; diff --git a/test/recipes.test.ts b/test/recipes.test.ts index d7ce6573..64baf733 100644 --- a/test/recipes.test.ts +++ b/test/recipes.test.ts @@ -6,8 +6,14 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { runSdpCli } from "../src/cli/sdp.js"; -import { createReader, extract, validateGraph } from "../src/index.js"; -import type { ExtractionResult } from "../src/index.js"; +import { + createReader, + extract, + extractValidatorId, + schemaVersion, + validateGraph, +} from "../src/index.js"; +import type { ExtractionResult, GraphEdge, GraphNode } from "../src/index.js"; import { expectedComponentIds, expectedUsesEdges } from "./self-hosting-oracle/structural-edges.js"; import { createCaptureOutput } from "./helpers/cli-capture.js"; @@ -297,20 +303,30 @@ function structuralGroundTruth() { }; } +const addressAndCycleRecipesImplementationAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.address-and-cycle-recipes"), + label: "asserts realization of the shipped address resolution and dependency cycle recipes", + satisfies: ref("spec:consumers.agent-surface.address-and-cycle-recipes"), +}); +void addressAndCycleRecipesImplementationAnchor; +const addressAndCycleRecipesTestAnchor = specTest({ + id: testAnchorId("test:protocol.address-and-cycle-recipes"), + label: "recipe checks verify the address resolution and dependency cycle recipes", + verifies: ref("spec:consumers.agent-surface.address-and-cycle-recipes"), +}); +void addressAndCycleRecipesTestAnchor; const registerRecipesImplementationAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.register-recipes"), label: "asserts realization of the shipped register recipes", satisfies: ref("spec:consumers.agent-surface.register-recipes"), }); void registerRecipesImplementationAnchor; - const registerRecipesTestAnchor = specTest({ id: testAnchorId("test:protocol.register-recipes"), label: "recipe checks verify the register recipes", verifies: ref("spec:consumers.agent-surface.register-recipes"), }); void registerRecipesTestAnchor; - describe("the agent-surface recipe corpus", () => { // Given: the catalog as authored. When: its structure is read. Then: every documented recipe // carries exactly one runnable body, so a new recipe cannot dodge the check by omitting one. @@ -393,6 +409,8 @@ describe("the agent-surface recipe corpus", () => { "twenty-two", "twenty-three", "twenty-four", + "twenty-five", + "twenty-six", ] as const; const countWord = countWords[recipes.length]; const lastOrdinal = recipes[recipes.length - 1]?.ordinal; @@ -413,7 +431,8 @@ describe("the agent-surface recipe corpus", () => { }; const parameterizedRecipes = recipes.filter( (recipe) => - recipe.ordinal !== 4 && /^(?:const (?:id|term|subject|scope) = )/u.test(recipe.body), + recipe.ordinal !== 4 && + /^(?:const (?:id|term|subject|scope|addresses) = )/u.test(recipe.body), ); // Recipe 4 receives filenames as data. Intro guidance must never teach callers to construct @@ -460,11 +479,13 @@ describe("the agent-surface recipe corpus", () => { "mention audit", "entry search", "pinned declarations", + "address resolution", + "dependency cycles", ]) { expect(agentSurfaceProse).toContain(phrase); } - for (const ordinal of [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]) { + for (const ordinal of [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]) { expect(onRamps.sessions).toContain(`recipe ${String(ordinal)}`); } }); @@ -811,6 +832,119 @@ describe("the agent-surface recipe corpus", () => { expect([...rungs, "none"]).toContain(stringAt(result, "floorReached")); expect(result.promotionRequiresHumanStatement).toBe(true); expect(Array.isArray(result.currentFloorFailures)).toBe(true); + // Every field the recipe reported stays, and the next rung's failures join them. + expect(Object.keys(result)).toEqual([ + "id", + "found", + "statedReadiness", + "floorReached", + "nextRung", + "currentFloorFailures", + "firstUnmetClause", + "nextRungFailures", + "nextRungFirstUnmetClause", + "promotionRequiresHumanStatement", + ]); + const context = reader.specContext(stringAt(result, "id")); + expect(result.nextRungFailures).toEqual( + JSON.parse(JSON.stringify(context?.nextRungFailures)) as unknown, + ); + expect(result.nextRungFirstUnmetClause).toBe(context?.nextRungFailures[0]?.clauseId ?? null); + }); + + it("names the next rung's unmet clause and the dependency that breaks it on a probe", async () => { + const recipe = recipeByOrdinal(9); + const catalogLine = 'const id = "spec:model.enrichment-lifecycle";'; + expect(recipe.body).toContain(catalogLine); + const retarget = (id: string) => ({ + ...recipe, + body: recipe.body.replace(catalogLine, `const id = "${id}";`), + }); + const ruleNode = ( + id: string, + readiness: "idea" | "scoped" | "defined" | "ready", + blocking = false, + ) => ({ + id, + nodeType: "Primitive" as const, + claim: "declared" as const, + specKind: "rule" as const, + altitude: "story" as const, + readiness, + title: id, + file: "specs/probe.sdp.md", + sections: { + intent: { + outcome: "Probe promotion preflight.", + ...(blocking ? { openQuestions: [{ question: "Settled?", blocking: true }] } : {}), + }, + behavior: { rules: ["The probe states one rule."] }, + }, + }); + const declared = (from: string, type: "refines" | "dependsOn" | "decidedBy", to: string) => ({ + from, + to, + type, + claim: "declared" as const, + }); + const probe: ExtractionResult = { + ...derived, + graph: { + schemaVersion: derived.graph.schemaVersion, + nodes: [ + ruleNode("spec:probe.subject", "defined"), + ruleNode("spec:probe.basis", "scoped"), + ruleNode("spec:probe.held", "scoped", true), + { + id: "impl:probe.code", + nodeType: "CodeNode", + claim: "anchored", + file: "src/probe.ts", + }, + ], + edges: [ + declared("spec:probe.subject", "dependsOn", "spec:probe.basis"), + declared("spec:probe.subject", "decidedBy", "impl:probe.code"), + declared("spec:probe.basis", "refines", "spec:probe.subject"), + declared("spec:probe.held", "refines", "spec:probe.subject"), + ], + }, + }; + const targetClause = "typed-dependency-targets-are-defined"; + + expect(await runRecipe(retarget("spec:probe.subject"), undefined, probe)).toEqual({ + id: "spec:probe.subject", + found: true, + statedReadiness: "defined", + floorReached: "defined", + nextRung: "ready", + currentFloorFailures: [], + firstUnmetClause: null, + nextRungFailures: [ + { + clauseId: targetClause, + description: + "Every refines, dependsOn, constrainedBy, and decidedBy target states at least defined.", + targets: [ + { type: "dependsOn", id: "spec:probe.basis", statedReadiness: "scoped" }, + { type: "decidedBy", id: "impl:probe.code" }, + ], + }, + ], + nextRungFirstUnmetClause: targetClause, + promotionRequiresHumanStatement: true, + }); + + // A failure of any other clause carries no targets field. + const held = asRecord(await runRecipe(retarget("spec:probe.held"), undefined, probe)); + expect(held.nextRung).toBe("defined"); + expect(held.nextRungFailures).toEqual([ + { + clauseId: "no-blocking-open-questions", + description: "Spec has no blocking open question in intent.openQuestions.", + }, + ]); + expect(held.nextRungFirstUnmetClause).toBe("no-blocking-open-questions"); }); it("keeps declared examples distinct from enabled verifier bindings", async () => { @@ -1696,6 +1830,7 @@ function registerProbe(): ExtractionResult { nodeType: "Pack", claim: "declared", title: "Probe pack", + members: [], file: "probe.pack.sdp.md", modelRefs: [], }, @@ -1774,18 +1909,21 @@ export const probe = spec({ } describe("register recipe semantics", () => { - it.each([20, 21, 22, 23, 24])("runs recipe %s as written in default output", async (ordinal) => { - const capture = createCaptureOutput(); - expect( - await runSdpCli( - ["q", recipeByOrdinal(ordinal).body, "--root", repoRoot], - capture.output, - queryHooks, - ), - ).toBe(0); - expect(capture.readStderr()).toBe(""); - expect(capture.readStdout()).toContain("totals:"); - }); + it.each([20, 21, 22, 23, 24, 25, 26])( + "runs recipe %s as written in default output", + async (ordinal) => { + const capture = createCaptureOutput(); + expect( + await runSdpCli( + ["q", recipeByOrdinal(ordinal).body, "--root", repoRoot], + capture.output, + queryHooks, + ), + ).toBe(0); + expect(capture.readStderr()).toBe(""); + expect(capture.readStdout()).toContain("totals:"); + }, + ); it("renders long JSON values with the same default text bound as strings", async () => { const extraction = carrierRegisterProbe(); @@ -1835,7 +1973,7 @@ describe("register recipe semantics", () => { }); expect(result.malformed).toEqual([]); expect(asArray(result.specs).map(asRecord)[0]?.questions).toEqual([ - { question: "Who owns this?", blocking: false }, + { question: "Who owns this?", blocking: false, key: null }, ]); }); @@ -1997,7 +2135,7 @@ describe("register recipe semantics", () => { check(entry, insideTotals || key === "totals"); } }; - for (const ordinal of [20, 21, 22, 23, 24]) { + for (const ordinal of [20, 21, 22, 23, 24, 25, 26]) { const output = asRecord(await runRecipe(recipeByOrdinal(ordinal))); check(output); for (const key of [ @@ -2009,6 +2147,8 @@ describe("register recipe semantics", () => { "reverseOnly", "tokens", "matches", + "rows", + "cycles", ]) { if (key in output) expect(Array.isArray(output[key])).toBe(true); } @@ -2097,8 +2237,8 @@ describe("register recipe semantics", () => { statedReadiness: "defined", totals: { blocking: 1 }, questions: [ - { blocking: true, question: "first" }, - { blocking: false, question: "second" }, + { blocking: true, question: "first", key: null }, + { blocking: false, question: "second", key: null }, ], }, { @@ -2106,8 +2246,8 @@ describe("register recipe semantics", () => { statedReadiness: "defined", totals: { blocking: 0 }, questions: [ - { blocking: false, question: "bare" }, - { blocking: false, question: "later" }, + { blocking: false, question: "bare", key: null }, + { blocking: false, question: "later", key: null }, ], }, { @@ -2120,7 +2260,7 @@ describe("register recipe semantics", () => { "RETRY.", "worker retry", "retry the worker", - ].map((question) => ({ blocking: false, question })), + ].map((question) => ({ blocking: false, question, key: null })), }, ], }); @@ -2666,6 +2806,41 @@ export const source = spec({ ]); }); + it.each([ + { question: "valid", key: "AggregateReach" }, + { question: "valid", key: "aggregate-reach" }, + { question: "valid", key: "" }, + { question: "valid", key: 7 }, + { question: "valid", key: null }, + { question: "valid", blocking: true, key: undefined }, + ])("reports an off-grammar question key as malformed: %j", async (authored) => { + const probe = registerProbe(); + const node = probe.graph.nodes.find((node) => node.id === "spec:probe.a"); + if (node?.nodeType !== "Primitive") throw new Error("missing question probe"); + const malformed = { + ...node, + sections: { intent: { openQuestions: [authored, { question: "kept", key: "kept" }] } }, + } as unknown as typeof node; + const result = asRecord( + await runRecipe(recipeByOrdinal(20), undefined, { + ...probe, + graph: { ...probe.graph, nodes: [malformed], edges: [] }, + }), + ); + expect(asRecord(result.totals).questions).toBe(1); + expect(asArray(result.specs).map(asRecord)[0]?.questions).toEqual([ + { blocking: false, question: "kept", key: "kept" }, + ]); + expect(result.malformed).toEqual([ + { + id: node.id, + section: "intent", + entry: "openQuestions[0]", + reason: "Expected a lower-camel question key", + }, + ]); + }); + it("searches whole tokens, coined keys, narrative and fence steps", async () => { const recipe = recipeByOrdinal(23); const search = async (term: string, extraction = registerProbe()) => @@ -2829,3 +3004,555 @@ export const source = spec({ }); }); }); + +/** Extracts a Markdown probe corpus from a temporary root the operating system names. */ +function markdownProbe(prefix: string, files: Readonly>): ExtractionResult { + const root = mkdtempSync(join(tmpdir(), prefix)); + try { + for (const [name, text] of Object.entries(files)) writeFileSync(join(root, name), text); + const extraction = extract({ root }); + expect(extraction.report.findings.filter((finding) => finding.severity === "error")).toEqual( + [], + ); + return extraction; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const cycleProbeSpec = (letter: string, dependsOn: readonly string[], design = ""): string => + `--- +id: spec:probe.${letter} +kind: rule +altitude: story +readiness: idea +relations: + dependsOn: +${dependsOn.map((target) => ` - spec:probe.${target}`).join("\n")} +--- +# Probe ${letter} + +## Intent +- outcome: Rest on other probe Specs. + +## Rule +- The probe states one rule. +${design}`; + +/** + * A synthetic extraction built in memory: `count` idea-rung rule Specs, each resting on the next + * through `dependsOn`, and the last on the first when `closed`. The probe is the recipe's walk over + * a graph this size, not the extractor, so the stub carries only what the query sink reads: the + * graph, an error-free report, and the counts. + */ +function dependencyChain(count: number, closed = false): ExtractionResult { + const ids = Array.from( + { length: count }, + (_, position) => `spec:probe.n${String(position).padStart(5, "0")}`, + ); + const nodes: GraphNode[] = ids.map((id) => ({ + id, + nodeType: "Primitive", + claim: "declared", + specKind: "rule", + altitude: "story", + readiness: "idea", + title: id, + file: `specs/${id}.sdp.md`, + sections: { intent: { outcome: "Rest on the next probe Spec." } }, + })); + const edges: GraphEdge[] = ids.slice(0, -1).map((from, position) => ({ + from, + type: "dependsOn", + to: ids[position + 1] ?? from, + claim: "declared", + })); + const [first] = ids; + const last = ids[ids.length - 1]; + if (closed && first !== undefined && last !== undefined) { + edges.push({ from: last, type: "dependsOn", to: first, claim: "declared" }); + } + return { + graph: { schemaVersion, nodes, edges }, + report: { validatorId: extractValidatorId, findings: [] }, + counts: { specs: count, packs: 0, anchors: 0 }, + }; +} + +/** The design's five-Spec probe: `a`, `b`, `c` rest on each other, `d` on itself, `e` on `a`. */ +function cycleProbe(): ExtractionResult { + return markdownProbe("sdp-cycle-probe-", { + "a.sdp.md": cycleProbeSpec("a", ["b"], "\n## Design\n- shape: A shape.\n"), + "b.sdp.md": cycleProbeSpec("b", ["a", "c"]), + "c.sdp.md": cycleProbeSpec("c", ["a"]), + "d.sdp.md": cycleProbeSpec("d", ["d"]), + "e.sdp.md": cycleProbeSpec("e", ["a"]), + }); +} + +/** One Spec with keyed and unkeyed open questions and a `description` in Design and UI. */ +function keyedQuestionProbe(): ExtractionResult { + return markdownProbe("sdp-keyed-question-probe-", { + "keyed.sdp.md": `--- +id: spec:probe.keyed +kind: rule +altitude: story +readiness: idea +relations: {} +--- +# Keyed probe + +## Intent +- outcome: Carry keyed questions. + +### Open questions +- [blocking #shapeOpen] Is the shape final? +- [non-blocking #description] Is description a key? +- [non-blocking] Who owns the page? + +## Rule +- The probe states one rule. + +## Design +Leading prose of the design. + +- shape: A shape. + +## UI +Leading prose of the page. + +- panel: One panel. +`, + "mentioning.sdp.md": `--- +id: spec:probe.mentioning +kind: rule +altitude: story +readiness: idea +relations: {} +--- +# Mentioning probe + +## Intent +- outcome: Cite spec:probe.keyed#question.shapeOpen, spec:probe.keyed#question.description, and spec:probe.keyed#question.missing. + +## Rule +- The probe states one rule. +`, + }); +} + +/** Recipe 25 with its opening parameter replaced, as the catalog tells a reader to do. */ +function addressResolution(addresses: readonly unknown[]): Recipe { + const recipe = recipeByOrdinal(25); + const opening = /^const addresses = \[\n[\s\S]*?\n\];\n/u; + expect(recipe.body).toMatch(opening); + return { + ...recipe, + body: recipe.body.replace(opening, `const addresses = ${JSON.stringify(addresses)};\n`), + }; +} + +/** + * The dependency cycles of a graph by mutual reachability, a second derivation independent of the + * recipe's own walk: the sets it must report, each with the length of the shortest closed path + * from its first member. + */ +function dependencyCycleOracle(extraction: ExtractionResult) { + const ids = extraction.graph.nodes + .filter((node) => node.nodeType === "Primitive") + .map((node) => node.id); + const known = new Set(ids); + const targets = new Map(ids.map((id) => [id, new Set()])); + for (const edge of extraction.graph.edges) { + if (edge.type === "dependsOn" && edge.claim === "declared" && known.has(edge.to)) + targets.get(edge.from)?.add(edge.to); + } + const reachableFrom = (start: string): Set => { + const seen = new Set(); + const queue = [...(targets.get(start) ?? [])]; + for (const next of queue) { + if (seen.has(next)) continue; + seen.add(next); + queue.push(...(targets.get(next) ?? [])); + } + return seen; + }; + const reach = new Map(ids.map((id) => [id, reachableFrom(id)])); + const sets = new Map(); + for (const id of ids) { + const members = ids + .filter((other) => other === id || (reach.get(id)?.has(other) && reach.get(other)?.has(id))) + .sort(); + if (members.length > 1 || targets.get(id)?.has(id)) sets.set(members.join(" "), members); + } + const shortestReturn = (members: readonly string[]): number => { + const inside = new Set(members); + const start = members[0] ?? ""; + const distance = new Map([[start, 0]]); + const queue = [start]; + let best = Number.POSITIVE_INFINITY; + for (const current of queue) { + for (const next of targets.get(current) ?? []) { + if (!inside.has(next)) continue; + const steps = (distance.get(current) ?? 0) + 1; + if (next === start) best = Math.min(best, steps); + if (!distance.has(next)) { + distance.set(next, steps); + queue.push(next); + } + } + } + return best; + }; + return { + targets, + sets: [...sets.values()] + .sort((left, right) => ((left[0] ?? "") < (right[0] ?? "") ? -1 : 1)) + .map((members) => ({ members, length: shortestReturn(members) })), + }; +} + +/** Checks a recipe 26 answer against the oracle: the same sets, each with a shortest closed path. */ +function expectCyclesToMatchOracle(result: Record, extraction: ExtractionResult) { + const oracle = dependencyCycleOracle(extraction); + const cycles = asArray(result.cycles).map(asRecord); + expect(cycles.map((entry) => entry.members)).toEqual(oracle.sets.map((set) => set.members)); + cycles.forEach((entry, index) => { + const members = asArray(entry.members).map(String); + const cycle = asArray(entry.cycle).map(String); + expect(cycle[0]).toBe(members[0]); + expect(cycle[cycle.length - 1]).toBe(members[0]); + expect(cycle.length - 1).toBe(oracle.sets[index]?.length); + for (let step = 1; step < cycle.length; step += 1) { + expect(members).toContain(cycle[step]); + expect(oracle.targets.get(cycle[step - 1] ?? "")?.has(cycle[step] ?? "")).toBe(true); + } + }); + expect(result.totals).toEqual({ + sets: oracle.sets.filter((set) => set.members.length > 1).length, + selfDependent: oracle.sets.filter((set) => set.members.length === 1).length, + specsInCycles: oracle.sets.reduce((sum, set) => sum + set.members.length, 0), + }); +} + +describe("address resolution and dependency cycles", () => { + it("resolves the catalog's addresses on this corpus as written", async () => { + expect(await runRecipe(recipeByOrdinal(25))).toEqual({ + totals: { addresses: 4, resolved: 1, malformed: 1, spec: 1, entry: 1 }, + rows: [ + { + address: "spec:consumers.design-review#ui.packPage", + resolves: true, + id: "spec:consumers.design-review", + section: "ui", + key: "packPage", + reason: null, + }, + ...[ + ["spec:consumers.design-review#ui.memberTable", "entry"], + ["spec:consumers.absent#design.anyKey", "spec"], + ["spec:consumers.design-review", "malformed"], + ].map(([address, reason]) => ({ + address, + resolves: false, + id: null, + section: null, + key: null, + reason, + })), + ], + }); + }); + + it("reports this corpus's dependency cycles as an independent reachability check finds them", async () => { + const result = asRecord(await runRecipe(recipeByOrdinal(26))); + expectCyclesToMatchOracle(result, derived); + // The design's answer on this corpus: no Spec rests on itself through `dependsOn`. + expect(result.cycles).toEqual([]); + }); + + it("reports the probe's one set and one self-dependent Spec, and nothing for a Spec that only reaches a set", async () => { + const extraction = cycleProbe(); + const result = asRecord(await runRecipe(recipeByOrdinal(26), undefined, extraction)); + expect(result).toEqual({ + totals: { sets: 1, selfDependent: 1, specsInCycles: 4 }, + cycles: [ + { + members: ["spec:probe.a", "spec:probe.b", "spec:probe.c"], + cycle: ["spec:probe.a", "spec:probe.b", "spec:probe.a"], + }, + { members: ["spec:probe.d"], cycle: ["spec:probe.d", "spec:probe.d"] }, + ], + }); + expectCyclesToMatchOracle(result, extraction); + }); + + it("reports a self-loop inside a larger set only with that set", async () => { + const probe = cycleProbe(); + const extraction: ExtractionResult = { + ...probe, + graph: { + ...probe.graph, + edges: [ + ...probe.graph.edges, + { from: "spec:probe.b", to: "spec:probe.b", type: "dependsOn", claim: "declared" }, + { from: "spec:probe.e", to: "spec:probe.absent", type: "dependsOn", claim: "declared" }, + ], + }, + }; + const result = asRecord(await runRecipe(recipeByOrdinal(26), undefined, extraction)); + expect(result.totals).toEqual({ sets: 1, selfDependent: 1, specsInCycles: 4 }); + expect(asArray(result.cycles).map((entry) => asRecord(entry).members)).toEqual([ + ["spec:probe.a", "spec:probe.b", "spec:probe.c"], + ["spec:probe.d"], + ]); + expectCyclesToMatchOracle(result, extraction); + }); + + it("walks targets in code-unit order, so of two tied shortest cycles the first target's is reported", async () => { + // `A` rests on `Z` and on `b`, and each rests back on `A`: two closed paths of two steps. + // In code units `Z` comes before `b`; reversed or alphabetical order would pick `b`. + const extraction = markdownProbe("sdp-cycle-tie-probe-", { + "start.sdp.md": cycleProbeSpec("A", ["b", "Z"]), + "upper.sdp.md": cycleProbeSpec("Z", ["A"]), + "lower.sdp.md": cycleProbeSpec("b", ["A"]), + }); + const result = asRecord(await runRecipe(recipeByOrdinal(26), undefined, extraction)); + expect(result).toEqual({ + totals: { sets: 1, selfDependent: 0, specsInCycles: 3 }, + cycles: [ + { + members: ["spec:probe.A", "spec:probe.Z", "spec:probe.b"], + cycle: ["spec:probe.A", "spec:probe.Z", "spec:probe.A"], + }, + ], + }); + expectCyclesToMatchOracle(result, extraction); + }); + + // A chain this long overflows a recursive walk; the recipe's explicit stack must not. + it("walks a chain of 10,000 Specs and one cycle of 5,000 without recursing", async () => { + const chain = asRecord( + await runRecipe(recipeByOrdinal(26), undefined, dependencyChain(10_000)), + ); + expect(chain).toEqual({ + totals: { sets: 0, selfDependent: 0, specsInCycles: 0 }, + cycles: [], + }); + + const ring = dependencyChain(5_000, true); + const ids = ring.graph.nodes.map((node) => node.id); + const result = asRecord(await runRecipe(recipeByOrdinal(26), undefined, ring)); + expect(result.totals).toEqual({ sets: 1, selfDependent: 0, specsInCycles: 5_000 }); + const [only, ...rest] = asArray(result.cycles).map(asRecord); + expect(rest).toEqual([]); + expect(only?.members).toEqual(ids); + expect(only?.cycle).toEqual([...ids, ids[0]]); + }); + + it("resolves an inherited property name only where the Spec authors it as a key", async () => { + const designProbeSpec = (leaf: string, entry: string): string => `--- +id: spec:probe.${leaf} +kind: rule +altitude: story +readiness: idea +relations: {} +--- +# Probe ${leaf} + +## Intent +- outcome: Carry one Design entry. + +## Rule +- The probe states one rule. + +## Design +- ${entry} +`; + const extraction = markdownProbe("sdp-inherited-key-probe-", { + "plain.sdp.md": designProbeSpec("plain", "shape: A shape."), + "authored.sdp.md": designProbeSpec("authored", "constructor: An authored constructor."), + }); + const result = await runRecipe( + addressResolution([ + "spec:probe.plain#design.constructor", + "spec:probe.authored#design.constructor", + ]), + undefined, + extraction, + ); + expect(result).toEqual({ + totals: { addresses: 2, resolved: 1, malformed: 0, spec: 0, entry: 1 }, + rows: [ + { + address: "spec:probe.plain#design.constructor", + resolves: false, + id: null, + section: null, + key: null, + reason: "entry", + }, + { + address: "spec:probe.authored#design.constructor", + resolves: true, + id: "spec:probe.authored", + section: "design", + key: "constructor", + reason: null, + }, + ], + }); + }); + + it("resolves the design's probe addresses with one row and one reason each", async () => { + const result = await runRecipe( + addressResolution([ + "spec:probe.a#design.shape", + "spec:probe.a#question.shapeOpen", + "spec:probe.z#design.shape", + "spec:probe.a#Design.shape", + ]), + undefined, + cycleProbe(), + ); + expect(result).toEqual({ + totals: { addresses: 4, resolved: 1, malformed: 1, spec: 1, entry: 1 }, + rows: [ + { + address: "spec:probe.a#design.shape", + resolves: true, + id: "spec:probe.a", + section: "design", + key: "shape", + reason: null, + }, + ...[ + ["spec:probe.a#question.shapeOpen", "entry"], + ["spec:probe.z#design.shape", "spec"], + ["spec:probe.a#Design.shape", "malformed"], + ].map(([address, reason]) => ({ + address, + resolves: false, + id: null, + section: null, + key: null, + reason, + })), + ], + }); + }); + + it("resolves a keyed question, refuses description in Design and UI, and keeps repeats", async () => { + const extraction = keyedQuestionProbe(); + const sections = createReader(extraction.graph).specContext("spec:probe.keyed")?.sections; + expect(sections?.design).toHaveProperty("description"); + expect(sections?.ui).toHaveProperty("description"); + const resolved = (address: string, section: string, key: string) => ({ + address, + resolves: true, + id: "spec:probe.keyed", + section, + key, + reason: null, + }); + const refused = (address: unknown, reason: string) => ({ + address, + resolves: false, + id: null, + section: null, + key: null, + reason, + }); + const result = await runRecipe( + addressResolution([ + "spec:probe.keyed#question.shapeOpen", + "spec:probe.keyed#question.description", + "spec:probe.keyed#design.description", + "spec:probe.keyed#ui.description", + "spec:probe.keyed#question.ownerOpen", + "spec:probe.keyed#design.shape", + "spec:probe.keyed", + 42, + "pack:probe.keyed#design.shape", + "spec:probe.keyed#question.shapeOpen", + ]), + undefined, + extraction, + ); + expect(result).toEqual({ + totals: { addresses: 10, resolved: 4, malformed: 3, spec: 0, entry: 3 }, + rows: [ + resolved("spec:probe.keyed#question.shapeOpen", "question", "shapeOpen"), + resolved("spec:probe.keyed#question.description", "question", "description"), + refused("spec:probe.keyed#design.description", "entry"), + refused("spec:probe.keyed#ui.description", "entry"), + refused("spec:probe.keyed#question.ownerOpen", "entry"), + resolved("spec:probe.keyed#design.shape", "design", "shape"), + refused("spec:probe.keyed", "malformed"), + refused(42, "malformed"), + refused("pack:probe.keyed#design.shape", "malformed"), + resolved("spec:probe.keyed#question.shapeOpen", "question", "shapeOpen"), + ], + }); + }); + + it("registers a keyed question with its key and an unkeyed one with a null key", async () => { + const result = asRecord(await runRecipe(recipeByOrdinal(20), undefined, keyedQuestionProbe())); + expect(asArray(result.specs).map(asRecord)[0]?.questions).toEqual([ + { blocking: true, question: "Is the shape final?", key: "shapeOpen" }, + { blocking: false, question: "Is description a key?", key: "description" }, + { blocking: false, question: "Who owns the page?", key: null }, + ]); + }); + + it("audits a question address against the target's open question keys", async () => { + const result = asRecord(await runRecipe(recipeByOrdinal(22), undefined, keyedQuestionProbe())); + expect(result.unresolved).toEqual([ + { + from: "spec:probe.mentioning", + to: "spec:probe.keyed#question.missing", + reason: "entry", + totals: { occurrences: 1 }, + at: [{ section: "intent", entry: "outcome" }], + }, + ]); + expect(asRecord(result.totals).occurrences).toBe(3); + }); + + it("addresses a keyed question's text and matches its key, never the key as a row", async () => { + const recipe = recipeByOrdinal(23); + const search = async (term: string) => + asArray( + asRecord( + await runRecipe( + { + ...recipe, + body: recipe.body.replace('const term = "suffix";', `const term = "${term}";`), + }, + undefined, + keyedQuestionProbe(), + ), + ).matches, + ) + .map(asRecord) + .filter((row) => row.id === "spec:probe.keyed"); + expect(await search("shape open")).toEqual([ + { + id: "spec:probe.keyed", + section: "intent", + entry: "openQuestions[0].question", + address: "spec:probe.keyed#question.shapeOpen", + matchedIn: ["key"], + text: "Is the shape final?", + }, + ]); + const owner = await search("owns"); + expect(owner.map((row) => [row.entry, row.address])).toEqual([ + ["openQuestions[2].question", null], + ]); + const description = await search("description"); + expect(description.map((row) => [row.entry, row.address, row.matchedIn])).toEqual([ + ["openQuestions[1].question", "spec:probe.keyed#question.description", ["key", "text"]], + ]); + }); +}); diff --git a/test/self-hosting-graph.test.ts b/test/self-hosting-graph.test.ts index a6b483f3..ab032214 100644 --- a/test/self-hosting-graph.test.ts +++ b/test/self-hosting-graph.test.ts @@ -31,6 +31,8 @@ import { expectedDeclaredRelations, expectedMemberOfEdges, expectedPackMembers, + expectedStudioPackMembers, + expectedPacks, expectedSpecs, expectedUsesEdges, expectedWarnings, @@ -115,7 +117,7 @@ const result = extract({ // When: the root corpus is reified through the public extractor. const nodeIds = result.graph.nodes.map((node) => node.id).sort(); const primitiveNodes = result.graph.nodes.filter((node) => node.nodeType === "Primitive"); -const packNode = result.graph.nodes.find((node) => node.id === "pack:self-hosting-v1"); +const packNodes = result.graph.nodes.filter((node) => node.nodeType === "Pack"); const byId = (left: { id: string }, right: { id: string }): number => left.id.localeCompare(right.id); @@ -191,12 +193,12 @@ describe("the self-hosting corpus", () => { // The literals are the corpus checkpoint. The authored arrays are measured against the same // literals rather than standing in for them, so a transcription slip in an oracle module // cannot certify itself by moving both sides of a comparison at once. - expect(result.counts).toEqual({ specs: 202, packs: 1, anchors: 220 }); - expect(expectedSpecs).toHaveLength(202); - expect(expectedPackMembers).toHaveLength(202); - expect(expectedAnchors).toHaveLength(220); - expect(result.graph.nodes).toHaveLength(423); - expect(result.graph.edges).toHaveLength(934); + expect(result.counts).toEqual({ specs: 225, packs: 2, anchors: 233 }); + expect(expectedSpecs).toHaveLength(225); + expect(expectedPackMembers).toHaveLength(225); + expect(expectedAnchors).toHaveLength(233); + expect(result.graph.nodes).toHaveLength(460); + expect(result.graph.edges).toHaveLength(1055); }); it("rosters exactly the authored Spec, Pack, and anchor node ids", () => { @@ -204,7 +206,7 @@ describe("the self-hosting corpus", () => { // read: one oracle statement of the corpus's identities, compared against the graph once. expect(nodeIds).toEqual( [ - "pack:self-hosting-v1", + ...expectedPacks.map((pack) => pack.id), ...expectedSpecs.map((spec) => spec.id), ...expectedAnchors.map((anchor) => anchor.id), ].sort(), @@ -246,7 +248,7 @@ describe("the self-hosting corpus", () => { }), {}, ), - ).toEqual({ defined: 48, idea: 4, ready: 148, scoped: 2 }); + ).toEqual({ defined: 58, idea: 4, ready: 150, scoped: 13 }); }); it("derives the Pack membership edges from the manifest, in manifest order", () => { @@ -254,19 +256,14 @@ describe("the self-hosting corpus", () => { result.graph.edges .filter((edge) => edge.type === "belongsTo") .map((edge) => [edge.from, edge.to, edge.claim]), - ).toEqual(expectedPackMembers.map((id) => [id, "pack:self-hosting-v1", "declared"])); + ).toEqual([ + ...expectedStudioPackMembers.map((id) => [id, "pack:spec-studio-v1", "declared"]), + ...expectedPackMembers.map((id) => [id, "pack:self-hosting-v1", "declared"]), + ]); }); - it("carries the Pack aggregate node exactly as authored", () => { - expect(packNode).toEqual({ - id: "pack:self-hosting-v1", - nodeType: "Pack", - claim: "declared", - title: "Self-hosting", - framing: "The Protocol authors and validates its own delivery model.", - modelRefs: ["spec:model.protocol-domain", "spec:model.core-model"], - file: "specs/self-hosting.pack.sdp.md", - }); + it("carries the Pack aggregate nodes exactly as authored", () => { + expect(packNodes).toEqual(expectedPacks); }); it("derives one binding edge per authored anchor", () => { diff --git a/test/self-hosting-next-rung-floor.test.ts b/test/self-hosting-next-rung-floor.test.ts new file mode 100644 index 00000000..af4509b9 --- /dev/null +++ b/test/self-hosting-next-rung-floor.test.ts @@ -0,0 +1,260 @@ +import { expect } from "vitest"; +import { + oracleAnchorId, + ref, + specOracle, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { unspecified } from "@libar-dev/software-delivery-protocol/runner"; +import { blockingQuestionContract } from "../generated/contracts/validation.next-rung-floor.blocking-question.contract.js"; +import { typedDependencyTargetsContract } from "../generated/contracts/validation.next-rung-floor.typed-dependency-targets.contract.js"; +import type { + NextRungFloorConditions, + NextRungFloorOutcome, +} from "../generated/contracts/validation.next-rung-floor.space.js"; +import { createReader, schemaVersion } from "../src/index.js"; +import type { + GraphEdge, + PrimitiveNode, + ReadinessFloorFailure, + SpecContext, + SpecReadiness, +} from "../src/index.js"; +import { paramsForStep } from "./helpers/generated-contract.js"; +import { registerBlockingQuestion } from "./validation.next-rung-floor.blocking-question.test.generated.js"; +import { registerTypedDependencyTargets } from "./validation.next-rung-floor.typed-dependency-targets.test.generated.js"; + +const FLOOR_STEP = "the floor reached is {floorReached} and the next rung is {nextRung}"; +const CLAUSE_STEP = "the next rung's first unmet clause is {clauseId}"; +const TARGETS_STEP = "that failure names the targets {targets}"; +const CURRENT_STEP = "the stated rung's floor failures number {currentFailures}"; +const DEPENDS = "depends on a spec stating scoped"; +const BLOCKING = "records a blocking open question"; +const BASIS_ID = "spec:probe.basis"; +const GROUND_ID = "spec:probe.ground"; +const PARENT_ID = "spec:probe.parent"; + +/** Every Then value of one point, written the way the example Specs write them. */ +interface NextRungAnswer { + readonly floorReached: "scoped" | "defined"; + readonly nextRung: "defined" | "ready"; + readonly clauseId: string; + readonly targets: string; + readonly currentFailures: number; +} + +const nextRungOracleAnchor = specOracle({ + id: oracleAnchorId("oracle:protocol.next-rung-floor"), + label: "expected next-rung clause and targets by structure", + models: ref("spec:validation.next-rung-floor"), +}); +void nextRungOracleAnchor; + +/** + * Authored from the Spec's rules, independently of the evaluator. A Spec resting on a `scoped` + * dependency clears its own floor through `defined` and fails `ready` on that dependency alone. + * A Spec with a blocking question clears `scoped` and fails `defined` on the question, which names + * no target; it states `scoped` honestly and `defined` with that one failure. + */ +function answerFor(point: Partial): NextRungAnswer | undefined { + if (point.specId === undefined || point.statedReadiness === undefined) return undefined; + switch (point.structure) { + case DEPENDS: + return { + floorReached: "defined", + nextRung: "ready", + clauseId: "typed-dependency-targets-are-defined", + targets: `dependsOn ${BASIS_ID} (scoped)`, + currentFailures: 0, + }; + case BLOCKING: + return { + floorReached: "scoped", + nextRung: "defined", + clauseId: "no-blocking-open-questions", + targets: "", + currentFailures: point.statedReadiness === "defined" ? 1 : 0, + }; + default: + return undefined; + } +} + +function expectedNextRung(point: Partial): NextRungFloorOutcome { + const answer = answerFor(point); + return answer === undefined ? unspecified : { kind: CLAUSE_STEP, clauseId: answer.clauseId }; +} + +interface World { + readonly point: Partial; + readonly subjectId: string; + readonly nodes: PrimitiveNode[]; + readonly edges: GraphEdge[]; + context?: SpecContext; +} + +/** A story-altitude rule Spec with an Intent outcome and one rule, as both examples state. */ +function ruleSpec(id: string, readiness: SpecReadiness, blocking = false): PrimitiveNode { + return { + id, + nodeType: "Primitive", + claim: "declared", + specKind: "rule", + altitude: "story", + readiness, + title: id, + file: "specs/probe.sdp.md", + sections: { + intent: { + outcome: "Probe the next rung.", + ...(blocking + ? { openQuestions: [{ question: "Is the probe settled?", blocking: true }] } + : {}), + }, + behavior: { rules: ["The probe states one rule."] }, + }, + }; +} + +function declared(from: string, type: GraphEdge["type"], to: string): GraphEdge { + return { from, to, type, claim: "declared" }; +} + +function createWorld(point: Partial): World { + const subjectId = point.specId ?? ""; + const stated = point.statedReadiness ?? "idea"; + switch (point.structure) { + case DEPENDS: + return { + point, + subjectId, + nodes: [ + ruleSpec(subjectId, stated), + ruleSpec(BASIS_ID, "scoped"), + ruleSpec(GROUND_ID, "idea"), + ], + edges: [ + declared(subjectId, "dependsOn", BASIS_ID), + declared(BASIS_ID, "refines", GROUND_ID), + ], + }; + case BLOCKING: + return { + point, + subjectId, + nodes: [ruleSpec(subjectId, stated, true), ruleSpec(PARENT_ID, "ready")], + edges: [declared(subjectId, "refines", PARENT_ID)], + }; + default: + throw new Error(`Unknown structure: ${String(point.structure)}`); + } +} + +function invoke(world: World): void { + world.context = createReader({ + schemaVersion, + nodes: world.nodes, + edges: world.edges, + }).specContext(world.subjectId); +} + +function contextOf(world: World): SpecContext { + if (world.context === undefined) throw new Error("The reader must build the spec's context."); + return world.context; +} + +/** A target is its relation type, its id and its stated rung in parentheses, joined by `, `. */ +function writeTargets(failure: ReadinessFloorFailure | undefined): string { + return (failure?.targets ?? []) + .map((target) => `${target.type} ${target.id} (${target.statedReadiness ?? "not a Spec"})`) + .join(", "); +} + +function observedAnswer(world: World): NextRungAnswer { + const context = contextOf(world); + const rungs = ["idea", "scoped", "defined", "ready"] as const; + const floorReached = context.derivedReadiness; + const nextRung = rungs[floorReached === undefined ? 0 : rungs.indexOf(floorReached) + 1]; + if (floorReached !== "scoped" && floorReached !== "defined") { + throw new Error(`The probe derived an unexpected floor: ${String(floorReached)}`); + } + if (nextRung !== "defined" && nextRung !== "ready") { + throw new Error(`The probe has an unexpected next rung: ${String(nextRung)}`); + } + const first = context.nextRungFailures[0]; + return { + floorReached, + nextRung, + clauseId: first?.clauseId ?? "", + targets: writeTargets(first), + currentFailures: context.floorFailures.length, + }; +} + +function observe(world: World): NextRungFloorOutcome { + return { kind: CLAUSE_STEP, clauseId: observedAnswer(world).clauseId }; +} + +type NextRungContract = typeof typedDependencyTargetsContract | typeof blockingQuestionContract; + +/** The Then values the example Spec states, read from its generated contract. */ +function statedAnswer(contract: NextRungContract): NextRungAnswer { + const floor = paramsForStep(contract, FLOOR_STEP); + return { + floorReached: floor.floorReached, + nextRung: floor.nextRung, + clauseId: paramsForStep(contract, CLAUSE_STEP).clauseId, + targets: paramsForStep(contract, TARGETS_STEP).targets, + currentFailures: paramsForStep(contract, CURRENT_STEP).currentFailures, + }; +} + +/** + * The comparator checks the clause step; these assertions check every other Then the same two + * ways: the Spec's value against the oracle, and the observed value against the oracle. + */ +function assertionsFor(contract: NextRungContract): (world: World) => void { + return (world) => { + const answer = answerFor(world.point); + expect(statedAnswer(contract)).toEqual(answer); + expect(observedAnswer(world)).toEqual(answer); + const context = contextOf(world); + // Only the typed-dependency clause carries targets, and the next rung's failures are exactly + // the next rung's own clauses: no lower-rung clause reappears in them. + for (const failure of context.nextRungFailures) { + expect(Object.hasOwn(failure, "targets")).toBe( + failure.clauseId === "typed-dependency-targets-are-defined", + ); + } + expect(context.nextRungFailures.map((failure) => failure.clauseId)).toEqual([answer?.clauseId]); + }; +} + +const typedDependencyTargetsTestAnchor = specTest({ + id: testAnchorId("test:protocol.next-rung-floor.typed-dependency-targets"), + label: "verifies the next rung's failure names the dependency stated below defined", + verifies: ref("spec:validation.next-rung-floor.typed-dependency-targets"), +}); +void typedDependencyTargetsTestAnchor; +registerTypedDependencyTargets({ + createWorld, + invoke, + observe, + expected: expectedNextRung, + assertions: assertionsFor(typedDependencyTargetsContract), +}); + +const blockingQuestionTestAnchor = specTest({ + id: testAnchorId("test:protocol.next-rung-floor.blocking-question"), + label: "verifies the next rung's failure names a blocking question and no target", + verifies: ref("spec:validation.next-rung-floor.blocking-question"), +}); +void blockingQuestionTestAnchor; +registerBlockingQuestion({ + createWorld, + invoke, + observe, + expected: expectedNextRung, + assertions: assertionsFor(blockingQuestionContract), +}); diff --git a/test/self-hosting-open-question-keys.test.ts b/test/self-hosting-open-question-keys.test.ts new file mode 100644 index 00000000..72a2275f --- /dev/null +++ b/test/self-hosting-open-question-keys.test.ts @@ -0,0 +1,369 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, expect } from "vitest"; + +import { + oracleAnchorId, + ref, + specOracle, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol"; +import { unspecified } from "@libar-dev/software-delivery-protocol/runner"; + +import { keyedQuestionResolvesContract } from "../generated/contracts/model.open-question-keys.keyed-question-resolves.contract.js"; +import { renamedKeyBreaksContract } from "../generated/contracts/model.open-question-keys.renamed-key-breaks.contract.js"; +import { repeatedKeyRefusedContract } from "../generated/contracts/model.open-question-keys.repeated-key-refused.contract.js"; +import type { + OpenQuestionKeysConditions, + OpenQuestionKeysOutcome, +} from "../generated/contracts/model.open-question-keys.space.js"; +import { extract, graphValidatorIds, validateGraph } from "../src/index.js"; +import type { ExtractionResult, Finding, PrimitiveNode } from "../src/index.js"; +import { paramsForStep } from "./helpers/generated-contract.js"; +import { registerKeyedQuestionResolves } from "./model.open-question-keys.keyed-question-resolves.test.generated.js"; +import { registerRenamedKeyBreaks } from "./model.open-question-keys.renamed-key-breaks.test.generated.js"; +import { registerRepeatedKeyRefused } from "./model.open-question-keys.repeated-key-refused.test.generated.js"; + +const REIFIED_STEP = "the first carrier is reified: {reified}"; +const EXTRACT_STEP = "the extraction findings name {extractMessage}"; +const KEYS_STEP = "the graph holds the question keys {keys}"; +const MENTIONS_STEP = "the report holds {mentionErrors} prose-mention errors"; +const MENTIONING_ID = "spec:probe.mentioning"; +const SUBJECT_FILE = "subject.sdp.md"; +const MENTIONING_FILE = "mentioning.sdp.md"; +const QUESTION_KEY = /^[a-z][A-Za-z0-9]*$/u; +const QUESTION_LINE = /^- \[(?:blocking|non-blocking)(?: #([^\]]*))?\] \S.*$/u; +const QUESTION_PARTS = /^- \[(blocking|non-blocking)(?: #([^\]]*))?\] (\S.*)$/u; + +/** Every Then value of one point, written the way the example Specs write them. */ +interface KeyAnswer { + readonly reified: boolean; + readonly extractMessage: string; + readonly keys: string; + readonly mentionErrors: number; +} + +const openQuestionKeysOracleAnchor = specOracle({ + id: oracleAnchorId("oracle:protocol.open-question-keys"), + label: "expected reification, question keys and mention errors by the key rules", + models: ref("spec:model.open-question-keys"), +}); +void openQuestionKeysOracleAnchor; + +/** The slot joins the carrier's open-question lines with a semicolon and a space. */ +function questionLines(questions: string): readonly string[] { + return questions.split("; "); +} + +/** + * Authored from the Spec's rules, independently of the extractor. A key off the grammar or one that + * repeats an earlier key refuses the first carrier whole, so the graph then holds no key and the + * address names a missing Spec. Otherwise the address resolves when it names the first Spec and a + * key one of its questions carries; every other address is one prose-mention error. + */ +function answerFor(point: Partial): KeyAnswer | undefined { + if (point.specId === undefined || point.questions === undefined || point.address === undefined) { + return undefined; + } + const keys: string[] = []; + let refusal = ""; + for (const line of questionLines(point.questions)) { + const key = QUESTION_LINE.exec(line)?.[1]; + if (key === undefined) continue; + if (!QUESTION_KEY.test(key)) { + refusal = "open question keys must be lower-camel ASCII"; + break; + } + if (keys.includes(key)) { + refusal = "open question keys must be unique"; + break; + } + keys.push(key); + } + const reified = refusal === ""; + const heldKeys = reified ? keys : []; + const [targetId, entry = ""] = point.address.split("#"); + const resolves = + reified && + targetId === point.specId && + entry.startsWith("question.") && + heldKeys.includes(entry.slice("question.".length)); + return { + reified, + extractMessage: refusal, + keys: heldKeys.join(", "), + mentionErrors: resolves ? 0 : 1, + }; +} + +/** + * The question objects a reified first carrier holds, in authored order: each line's text, whether + * its flag blocks, and its key when the marker carries one. An unkeyed line is a question too. + */ +function expectedQuestions(point: Partial): readonly unknown[] { + return questionLines(point.questions ?? "").flatMap((line) => { + const parts = QUESTION_PARTS.exec(line); + if (parts?.[1] === undefined || parts[3] === undefined) return []; + return [ + { + question: parts[3], + blocking: parts[1] === "blocking", + ...(parts[2] === undefined ? {} : { key: parts[2] }), + }, + ]; + }); +} + +function expectedOutcome(point: Partial): OpenQuestionKeysOutcome { + const answer = answerFor(point); + return answer === undefined + ? unspecified + : { kind: MENTIONS_STEP, mentionErrors: answer.mentionErrors }; +} + +interface KeyWorld { + readonly point: Partial; + readonly root: string; + readonly subjectSource: string; + result?: ExtractionResult; + validation?: readonly Finding[]; +} + +const temporaryRoots = new Set(); + +afterEach(() => { + for (const root of temporaryRoots) rmSync(root, { recursive: true, force: true }); + temporaryRoots.clear(); +}); + +/** + * The first carrier: a story-altitude rule Spec stating `scoped`, with an Intent outcome, the + * open-question lines of the slot, one rule, and one declared `refines` on the second. + */ +function subjectCarrier(specId: string, questions: string): string { + return `--- +id: ${specId} +kind: rule +altitude: story +readiness: scoped +relations: + refines: ${MENTIONING_ID} +--- +# Probe subject + +## Intent +- outcome: Carry the open questions of the key probe. + +### Open questions +${questionLines(questions).join("\n")} + +## Rule +- The probe states one rule. +`; +} + +/** + * The second carrier: a story-altitude rule Spec stating `idea`, with an Intent outcome and one + * rule, whose narrative names the address and which declares `dependsOn` on the first. + */ +function mentioningCarrier(specId: string, address: string): string { + return `--- +id: ${MENTIONING_ID} +kind: rule +altitude: story +readiness: idea +relations: + dependsOn: ${specId} +--- +# Probe mentioning + +The probe cites ${address} by its address. + +## Intent +- outcome: Name the subject's open question by its address. + +## Rule +- The probe states one rule. +`; +} + +function createWorld(point: Partial): KeyWorld { + const { specId, questions, address } = point; + if (specId === undefined || questions === undefined || address === undefined) { + throw new Error("The generated point must bind the Spec id, its questions and the address."); + } + const root = mkdtempSync(join(tmpdir(), "sdp-open-question-keys-")); + temporaryRoots.add(root); + const subjectSource = subjectCarrier(specId, questions); + writeFileSync(join(root, SUBJECT_FILE), subjectSource); + writeFileSync(join(root, MENTIONING_FILE), mentioningCarrier(specId, address)); + return { point, root, subjectSource }; +} + +function invoke(world: KeyWorld): void { + const result = extract({ root: world.root }); + world.result = result; + world.validation = validateGraph(result.graph).findings; +} + +function resultOf(world: KeyWorld): ExtractionResult { + if (world.result === undefined) throw new Error("The extractor must reify both carriers."); + return world.result; +} + +function mentionErrorsOf(world: KeyWorld): readonly Finding[] { + return (world.validation ?? []).filter( + (finding) => + finding.validatorId === graphValidatorIds.proseMentions && finding.severity === "error", + ); +} + +/** Every key an open question carries, over every Spec of the graph in node order. */ +function graphKeys(result: ExtractionResult): readonly string[] { + return result.graph.nodes + .filter((node): node is PrimitiveNode => node.nodeType === "Primitive") + .flatMap((node) => { + const intent = (node.sections as Record | undefined)?.intent; + const questions = (intent as Record | undefined)?.openQuestions; + return (Array.isArray(questions) ? questions : []).flatMap((question: unknown) => { + const key = (question as Record | null)?.key; + return typeof key === "string" ? [key] : []; + }); + }); +} + +/** The open questions of the first Spec as the graph holds them, whole. */ +function subjectQuestions(world: KeyWorld): unknown { + const subject = resultOf(world) + .graph.nodes.filter((node): node is PrimitiveNode => node.nodeType === "Primitive") + .find((node) => node.id === world.point.specId); + const intent = (subject?.sections as Record | undefined)?.intent; + return (intent as Record | undefined)?.openQuestions; +} + +function observedAnswer(world: KeyWorld): KeyAnswer { + const result = resultOf(world); + return { + reified: result.graph.nodes.some((node) => node.id === world.point.specId), + extractMessage: result.report.findings.map((finding) => finding.message).join("; "), + keys: graphKeys(result).join(", "), + mentionErrors: mentionErrorsOf(world).length, + }; +} + +function observe(world: KeyWorld): OpenQuestionKeysOutcome { + return { kind: MENTIONS_STEP, mentionErrors: observedAnswer(world).mentionErrors }; +} + +type KeyContract = + | typeof keyedQuestionResolvesContract + | typeof renamedKeyBreaksContract + | typeof repeatedKeyRefusedContract; + +/** The Then values the example Spec states, read from its generated contract. */ +function statedAnswer(contract: KeyContract): KeyAnswer { + return { + reified: paramsForStep(contract, REIFIED_STEP).reified, + extractMessage: paramsForStep(contract, EXTRACT_STEP).extractMessage, + keys: paramsForStep(contract, KEYS_STEP).keys, + mentionErrors: paramsForStep(contract, MENTIONS_STEP).mentionErrors, + }; +} + +/** The 1-based line of the first carrier's open question that repeats an earlier key. */ +function repeatedKeyLine(world: KeyWorld): number { + const lines = world.subjectSource.split("\n"); + const seen = new Set(); + const index = lines.findIndex((line) => { + const key = QUESTION_LINE.exec(line)?.[1]; + if (key === undefined) return false; + if (seen.has(key)) return true; + seen.add(key); + return false; + }); + return index + 1; +} + +/** + * The comparator checks the mention-error step; these assertions check every other Then the same + * two ways: the Spec's value against the oracle, and the observed value against the oracle. + */ +function assertionsFor(contract: KeyContract): (world: KeyWorld) => void { + return (world) => { + const answer = answerFor(world.point); + expect(statedAnswer(contract)).toEqual(answer); + expect(observedAnswer(world)).toEqual(answer); + const result = resultOf(world); + if (answer?.reified === true) { + // The whole question objects, so a question the key leaves alone cannot drop unseen. + expect(subjectQuestions(world)).toEqual(expectedQuestions(world.point)); + } + if (answer?.reified === false) { + // The refusal is the structure finding at the line of the repeated entry, and the address + // then names a missing Spec rather than a missing entry. + expect(result.report.findings).toMatchObject([ + { + validatorId: "extract/invalid-markdown-structure", + severity: "error", + file: SUBJECT_FILE, + line: repeatedKeyLine(world), + }, + ]); + expect(mentionErrorsOf(world)[0]?.message).toContain("points to missing target"); + } else if (answer?.mentionErrors === 1) { + expect(mentionErrorsOf(world)[0]?.message).toContain("points to missing entry"); + } + // The declared dependsOn backs the mention, so no prose-mention warning joins an error. + expect( + (world.validation ?? []).filter( + (finding) => + finding.validatorId === graphValidatorIds.proseMentions && finding.severity === "warning", + ), + ).toEqual([]); + }; +} + +const keyedQuestionResolvesTestAnchor = specTest({ + id: testAnchorId("test:protocol.open-question-keys.keyed-question-resolves"), + label: "verifies a keyed question is reified and its question address resolves", + verifies: ref("spec:model.open-question-keys.keyed-question-resolves"), +}); +void keyedQuestionResolvesTestAnchor; +registerKeyedQuestionResolves({ + createWorld, + invoke, + observe, + expected: expectedOutcome, + assertions: assertionsFor(keyedQuestionResolvesContract), +}); + +const renamedKeyBreaksTestAnchor = specTest({ + id: testAnchorId("test:protocol.open-question-keys.renamed-key-breaks"), + label: "verifies a renamed question key fails the mention of its old address", + verifies: ref("spec:model.open-question-keys.renamed-key-breaks"), +}); +void renamedKeyBreaksTestAnchor; +registerRenamedKeyBreaks({ + createWorld, + invoke, + observe, + expected: expectedOutcome, + assertions: assertionsFor(renamedKeyBreaksContract), +}); + +const repeatedKeyRefusedTestAnchor = specTest({ + id: testAnchorId("test:protocol.open-question-keys.repeated-key-refused"), + label: "verifies a repeated question key refuses its carrier whole", + verifies: ref("spec:model.open-question-keys.repeated-key-refused"), +}); +void repeatedKeyRefusedTestAnchor; +registerRepeatedKeyRefused({ + createWorld, + invoke, + observe, + expected: expectedOutcome, + assertions: assertionsFor(repeatedKeyRefusedContract), +}); diff --git a/test/self-hosting-oracle/anchors.ts b/test/self-hosting-oracle/anchors.ts index 43cd0f9a..aaa62a33 100644 --- a/test/self-hosting-oracle/anchors.ts +++ b/test/self-hosting-oracle/anchors.ts @@ -1449,7 +1449,7 @@ export const expectedAnchors = [ { id: "impl:protocol.pack-coherence", nodeType: "CodeNode", - label: "checks Pack membership uniqueness and model-reference kinds", + label: "checks Pack membership agreement and uniqueness and model-reference kinds", type: "satisfies", target: "spec:validation.pack-coherence", file: "src/validate/validators.ts", @@ -2207,4 +2207,135 @@ export const expectedAnchors = [ constant: "proseMentionsTestAnchor", site: 'describe("conformance/prose-mentions', }, + { + id: "impl:protocol.pack-member-order", + nodeType: "CodeNode", + label: "keeps manifest member order on the Pack node", + type: "satisfies", + target: "spec:extraction.pack-member-order", + file: "src/extract/derive.ts", + constant: "packMemberOrderAnchor", + site: "function derivePackNode", + }, + { + id: "impl:protocol.open-question-keys", + nodeType: "CodeNode", + label: + "reads an open question's optional key from its Markdown marker and refuses a bad or repeated key", + type: "satisfies", + target: "spec:model.open-question-keys", + file: "src/extract/markdown-body-owner-behavior.ts", + constant: "openQuestionKeysAnchor", + site: "const OPEN_QUESTION_MARKER", + }, + { + id: "impl:protocol.next-rung-floor", + nodeType: "CodeNode", + label: "evaluates the floor for a target rung and names the targets that break a dependency", + type: "satisfies", + target: "spec:validation.next-rung-floor", + file: "src/validate/readiness-floor.ts", + constant: "nextRungFloorAnchor", + site: "export function evaluateReadinessFloor", + }, + { + id: "impl:protocol.address-and-cycle-recipes", + nodeType: "CodeNode", + label: "asserts realization of the shipped address resolution and dependency cycle recipes", + type: "satisfies", + target: "spec:consumers.agent-surface.address-and-cycle-recipes", + file: "test/recipes.test.ts", + constant: "addressAndCycleRecipesImplementationAnchor", + site: 'describe("the agent-surface recipe corpus"', + }, + { + id: "test:protocol.address-and-cycle-recipes", + nodeType: "Anchor", + label: "recipe checks verify the address resolution and dependency cycle recipes", + type: "verifies", + target: "spec:consumers.agent-surface.address-and-cycle-recipes", + file: "test/recipes.test.ts", + constant: "addressAndCycleRecipesTestAnchor", + site: 'describe("the agent-surface recipe corpus"', + }, + { + id: "oracle:protocol.next-rung-floor", + nodeType: "Anchor", + label: "expected next-rung clause and targets by structure", + type: "models", + target: "spec:validation.next-rung-floor", + file: "test/self-hosting-next-rung-floor.test.ts", + constant: "nextRungOracleAnchor", + site: "function answerFor", + }, + { + id: "test:protocol.next-rung-floor.typed-dependency-targets", + nodeType: "Anchor", + label: "verifies the next rung's failure names the dependency stated below defined", + type: "verifies", + target: "spec:validation.next-rung-floor.typed-dependency-targets", + file: "test/self-hosting-next-rung-floor.test.ts", + constant: "typedDependencyTargetsTestAnchor", + site: "registerTypedDependencyTargets(", + }, + { + id: "test:protocol.next-rung-floor.blocking-question", + nodeType: "Anchor", + label: "verifies the next rung's failure names a blocking question and no target", + type: "verifies", + target: "spec:validation.next-rung-floor.blocking-question", + file: "test/self-hosting-next-rung-floor.test.ts", + constant: "blockingQuestionTestAnchor", + site: "registerBlockingQuestion(", + }, + { + id: "oracle:protocol.open-question-keys", + nodeType: "Anchor", + label: "expected reification, question keys and mention errors by the key rules", + type: "models", + target: "spec:model.open-question-keys", + file: "test/self-hosting-open-question-keys.test.ts", + constant: "openQuestionKeysOracleAnchor", + site: "function answerFor", + }, + { + id: "test:protocol.open-question-keys.keyed-question-resolves", + nodeType: "Anchor", + label: "verifies a keyed question is reified and its question address resolves", + type: "verifies", + target: "spec:model.open-question-keys.keyed-question-resolves", + file: "test/self-hosting-open-question-keys.test.ts", + constant: "keyedQuestionResolvesTestAnchor", + site: "registerKeyedQuestionResolves(", + }, + { + id: "test:protocol.open-question-keys.renamed-key-breaks", + nodeType: "Anchor", + label: "verifies a renamed question key fails the mention of its old address", + type: "verifies", + target: "spec:model.open-question-keys.renamed-key-breaks", + file: "test/self-hosting-open-question-keys.test.ts", + constant: "renamedKeyBreaksTestAnchor", + site: "registerRenamedKeyBreaks(", + }, + { + id: "test:protocol.open-question-keys.repeated-key-refused", + nodeType: "Anchor", + label: "verifies a repeated question key refuses its carrier whole", + type: "verifies", + target: "spec:model.open-question-keys.repeated-key-refused", + file: "test/self-hosting-open-question-keys.test.ts", + constant: "repeatedKeyRefusedTestAnchor", + site: "registerRepeatedKeyRefused(", + }, + { + id: "test:protocol.pack-member-order", + nodeType: "Anchor", + label: "verifies manifest order in the graph, reader and Pack page", + type: "verifies", + target: "spec:extraction.pack-member-order.manifest-order-kept", + file: "test/self-hosting-pack-member-order.test.ts", + constant: "packMemberOrderTestAnchor", + site: "registerManifestOrderKept(", + }, ] as const; diff --git a/test/self-hosting-oracle/carrier.ts b/test/self-hosting-oracle/carrier.ts index 821669ad..a3dc6736 100644 --- a/test/self-hosting-oracle/carrier.ts +++ b/test/self-hosting-oracle/carrier.ts @@ -873,7 +873,7 @@ export const carrierSpecs = [ "The refused block shapes are read line by line in narrative, section prose, and list text: a line that opens with a pipe, a block-quote marker, whitespace, a star or plus bullet, an ordered-list marker, or a `<`, and a line that is only a thematic break or a setext underline. A table without outer pipes is prose to the parser.", "Raw HTML is refused in narrative, section prose, and list text, and is not scanned in the H1 title or inside fence steps. The test matches an HTML tag with its closing `>`, a comment delimiter, a declaration, or a processing instruction, so `a < b` and an unclosed `Promise#
.` for a `design` or `ui` key matching `^[a-z][A-Za-z0-9]*$`, and `null` for every other entry, including `description` and off-grammar keys.", + "Entry search gives `spec:#
.` for a `design` or `ui` key matching `^[a-z][A-Za-z0-9]*$` and `spec:#question.` for the text of an open question that carries a key, and `null` for every other entry, including `description` and off-grammar keys.", "The pinned declarations list 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.", "Each body composes the existing reader. None adds a reader join or a CLI verb, because a join freezes into the reader only at the second-caller bar.", "The recipe check executes each body as written, and every document that states the catalog's size moves with the catalog.", "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.", - 'The mention audit takes a list of mentioning Spec ids, with an empty list selecting the whole corpus. It reports target Spec pairs and unresolved token pairs, keeping every location in `at` as section and entry. Unresolved rows carry `reason: "spec" | "entry" | "malformed"` for an absent Spec, an absent own section key or `description`, or a refused token. Pairs with no declared relation in either direction, and pairs backed only by a declared relation from the target are separate lists.', + 'The mention audit takes a list of mentioning Spec ids, with an empty list selecting the whole corpus. It reports target Spec pairs and unresolved token pairs, keeping every location in `at` as section and entry. Unresolved rows carry `reason: "spec" | "entry" | "malformed"` for an absent Spec; an absent own key of that `design` or `ui` section, `description` there, or a question key no open question of that Spec carries; or a refused token. Pairs with no declared relation in either direction, and pairs backed only by a declared relation from the target are separate lists.', ], }, }, @@ -1213,4 +1214,568 @@ export const consumersSpecs = [ }, deliveryFacts: ["implemented", "has-verifier"], }, + { + id: "spec:consumers.agent-surface.address-and-cycle-recipes", + specKind: "behavior", + altitude: "story", + readiness: "defined", + title: "Address checks and dependency cycles stay executable graph recipes", + narrative: null, + sections: { + intent: { + problem: + "An adopter that cites entry addresses outside its Specs resolves each one with its own grammar and its own lookup, and no recipe reports a cycle in `dependsOn`, so a Spec can rest on itself through a chain no one has traced.", + outcome: + "Resolve a list of entry addresses and report every `dependsOn` cycle with catalog recipes, so neither is rebuilt by hand.", + }, + behavior: { + rules: [ + 'Address resolution takes a list of entry addresses on its opening line and returns one row per input, in input order, repeats included. Each row carries `address`, `resolves`, `id`, `section`, `key`, and `reason`. A resolving row carries the Spec id, the section `design`, `ui`, or `question`, the key, and `reason: null`. A row that does not resolve carries `id`, `section`, and `key` as null and one reason: `"malformed"` for an input that is not an entry address, a bare Spec id included, `"spec"` for an address whose Spec is absent, and `"entry"` for an address whose Spec holds no such entry.', + "Address resolution reads the address grammar and the resolution rule of the checked-mentions record: a `design` or `ui` key resolves as an own key of that section other than `description`, and a `question` key resolves when one of the Spec's open questions carries it. Its totals count the inputs, the resolving rows, and each reason.", + "Dependency cycles take no parameter and read the whole graph. They report every set of two or more Specs in which each Spec reaches every other through declared `dependsOn` edges, and every Spec that declares `dependsOn` on itself and belongs to no such set. An edge whose target is not a Spec in the graph is ignored.", + "Each reported set lists its members sorted by id in code-unit order and one cycle through it as a closed path that starts and ends at its first member. The cycle is the shortest such path, found by a breadth-first walk from the first member over targets in code-unit order and restricted to the set; a Spec that depends only on itself reports the path of that Spec twice. Sets are ordered by their first member, and the totals count the sets, the self-dependent Specs, and the Specs in any set.", + "Both recipes report and never refuse: a cycle is data about the authored dependencies, as a structural cycle is, and the readiness floor reads each `dependsOn` target's stated rung without walking a chain.", + ], + }, + }, + deliveryFacts: ["implemented", "has-verifier"], + file: "specs/consumers/agent-surface.address-and-cycle-recipes.sdp.md", + }, + { + id: "spec:consumers.spec-studio.components", + specKind: "contract", + altitude: "story", + readiness: "scoped", + title: "The Studio's custom elements each render one view and embed anywhere", + narrative: null, + sections: { + intent: { + outcome: + "Give the Studio a small set of custom elements that each render one view of the graph, so one view serves a Studio page, a status report, a pull request description or a slide export.", + 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", + }, + { + question: + "The original's harness element takes a harness id and keeps its coverage current as a reader 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", + }, + ], + }, + behavior: { + rules: [ + "Each element is self-contained and light on dependencies, and works embedded in any HTML page.", + "Each element reads the one graph object its page sets, as `spec:consumers.spec-studio.data` states, and takes its subject as a graph id in an attribute.", + "Every element's name starts with `sdp-`.", + ], + }, + design: { + description: "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 missing tests.", + maturityMap: + "`sdp-maturity-map` with `pack-id` and `group-by`: a Pack's members by readiness, grouped by the named field, such as capability.", + harness: + "`sdp-harness` with `harness-id` and `auto-coverage`: an interactive scenario harness that keeps its coverage current.", + diagramView: + "`sdp-likec4-view` with `view-id`: a LikeC4 view, wrapping LikeC4's own runtime.", + 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 `id`: the composing panel on its own, in the place of the original's patch export button.", + validationFindings: + "`sdp-validation-findings` with `filter`: the validation report's findings for the Spec or Pack id the filter names.", + }, + }, + deliveryFacts: [], + file: "specs/consumers/spec-studio.components.sdp.md", + }, + { + 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, + 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 designed-in and deferred, and no Spec states the shape of a slice. Does the Studio's data hold a slice, and which Spec would state it?", + blocking: false, + key: "aiSlices", + }, + ], + }, + 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 nothing a page computes; a page derives no join, claim, delivery fact, readiness or floor failure from them.", + "One graph writes byte-identical data files.", + "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.", + ], + }, + 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: descriptors, narrative, sections, stated and derived readiness, `floorFailures` and `nextRungFailures`, relations both ways, implementation and verifier bindings, the oracle binding and the findings.", + packContexts: + "for each Pack, the reader's `packContext` value: framing, model references, the members in the manifest's authored order, verifier gaps 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.", + }, + }, + deliveryFacts: [], + file: "specs/consumers/spec-studio.data.sdp.md", + }, + { + id: "spec:consumers.spec-studio.intent-panel", + specKind: "behavior", + altitude: "story", + readiness: "scoped", + title: "A composing panel on every page gathers scoped intent for an agent", + narrative: null, + sections: { + intent: { + outcome: + "Let a reader 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: + "How does a static page hand composed intent to an agent: as a file the reader saves, as text the reader copies, or as a pull request? The original exports patch JSON or opens a pull request, and `spec:consumers.intent-composition` names no entrypoint for the composing surface.", + blocking: true, + key: "handOff", + }, + { + question: + "The original stages each change as a JSON patch, checks its schema in the browser and its effect on the graph through the CLI or an agent, and applies it to canonical source; `spec:consumers.edit-model` has a view compose scoped intent that an agent turns into an ordinary source edit, with no patch loop. Does any part of the patch loop return, or is scoped intent the panel's only output?", + blocking: false, + key: "patchLoop", + }, + ], + }, + behavior: { + rules: [ + "The composing panel is visible on every page.", + "The panel writes nothing: source changes only when an agent edits it and git records the edit, and the conformance and honesty checks judge that edit as they judge every edit.", + "A reader gathers intents across pages before handing them off.", + "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.", + ], + exampleSpace: { + given: ["the composing panel holds {gathered:number} gathered intents"], + when: ["the reader refreshes the tab"], + then: ["the composing panel holds {kept:number} gathered intents"], + }, + }, + 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.", + panelActions: "discard, hand off, and open as a pull request.", + composingActions: + "the actions on other panels that compose intent: resolve an open question or promote it to a decision record, propose or edit an example, propose or adjust a constraint target, and propose examples for missing combinations.", + }, + }, + deliveryFacts: [], + file: "specs/consumers/spec-studio.intent-panel.sdp.md", + }, + { + id: "spec:consumers.spec-studio.lenses", + specKind: "behavior", + altitude: "story", + readiness: "scoped", + title: "Four lenses read the graph as Packs, architecture, tests and evidence", + narrative: null, + sections: { + intent: { + outcome: + "Let a reader see the graph four ways, by Pack, by architecture, by verification and by evidence, from one top navigation.", + openQuestions: [ + { + question: + "The original's Packs lens groups Specs by Pack or by capability; a capability is a projection over high-altitude behavior Specs or a Pack grouping, no Capability Map projection exists, and grouping by architectural significance is derived from id families and the component graph. Which grouping does the capability tree read?", + blocking: false, + key: "capabilityGrouping", + }, + { + question: + "The original's architecture lens draws runtime layers, ports and external systems beside components and their dependencies; `spec:decisions.architectural-significance-rides-primitives` admits no structural vocabulary beyond component membership and `uses`. Does the lens draw only what those edges hold?", + blocking: false, + key: "architectureVocabulary", + }, + { + 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", + }, + { + question: + "The original's tests lens names the rules of a Spec that no test exercises; an example verifies a whole Spec and a rule entry has no address, so the graph cannot tell which rule an example exercises. Does a rule gain an address, or does the lens name the uncovered Specs only?", + blocking: false, + key: "ruleCoverage", + }, + ], + }, + behavior: { + rules: [ + "The Packs lens is the default landing page.", + "Clicking a component in the architecture lens opens its detail.", + "The evidence lens is read-only.", + ], + }, + ui: { + description: "The lenses in the order of the top navigation.", + packsLens: + "a Spec-by-Spec tree grouped by Pack or by capability, each Pack's members in authored order.", + 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.", + evidenceLens: + "build provenance, SBOM links, OpenTelemetry runtime observations and deployment history.", + }, + }, + deliveryFacts: [], + file: "specs/consumers/spec-studio.lenses.sdp.md", + }, + { + id: "spec:consumers.spec-studio.responsive", + specKind: "behavior", + altitude: "story", + readiness: "scoped", + title: "The Studio reads on a phone as on a desktop", + narrative: null, + sections: { + intent: { + actor: "A product manager or an executive reviewing a design on a phone.", + outcome: "Let a design be reviewed on a phone through the same pages a desktop shows.", + openQuestions: [ + { + question: + "The original's summary mode hides low-confidence inferred edges; a claim is declared, anchored or inferred and carries no confidence, and the curated graph holds no inferred edge, which only the aspirational impact graph would add. What does summary mode hide?", + blocking: false, + key: "summaryMode", + }, + ], + }, + behavior: { + rules: [ + "Every page of the Studio is responsive, so the same pages serve a desktop and a phone.", + ], + exampleSpace: { + given: ["a Studio page whose layout holds {wideColumns:number} columns"], + when: ["the page is shown {width:number} pixels wide"], + then: ["the layout holds {columns:number} columns"], + }, + }, + ui: { + singleColumn: "below about 720 pixels wide, every layout collapses to a single column.", + pinchZoom: "on a touch device, every diagram zooms with a pinch.", + summaryMode: + "below a set screen width, the trace graph and the component diagrams switch to a summary mode that hides low-confidence inferred edges.", + }, + }, + deliveryFacts: [], + file: "specs/consumers/spec-studio.responsive.sdp.md", + }, + { + id: "spec:consumers.spec-studio", + specKind: "behavior", + altitude: "feature", + readiness: "scoped", + title: "The Spec Studio is a static HTML workbench over the one graph", + narrative: null, + sections: { + intent: { + actor: + "A stakeholder who reviews a design, on a desktop or on a phone, and reads far more than they change.", + 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.", + openQuestions: [ + { + question: + "Which package carries the Studio: the open-source `@libar-dev/software-delivery-protocol`, or a commercial `@libar-ai/` package? The Studio's deferral re-enters on a recorded ruling that names this home; earlier planning placed the Studio under `@libar-ai/`, and no Spec rules it.", + blocking: true, + key: "packageHome", + }, + { + question: + "Which reader does the Studio serve that the generated Markdown Design Review does not? The first adopter generates its own Pack page beside the Design Review, with the members in reading order, the clause that holds each below its next rung, the Specs outside the Pack they rest on, and their open questions with register rows, while the Design Review's Pack page lists the members and the verifier gaps.", + blocking: true, + key: "unservedReader", + }, + ], + }, + behavior: { + rules: [ + "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 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 reader 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.", + "The Studio is built from custom elements, as `spec:decisions.studio-web-components` rules.", + "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}", + "the extraction root stays byte-identical: {rootUntouched:boolean}", + ], + }, + }, + }, + deliveryFacts: [], + file: "specs/consumers/spec-studio.sdp.md", + }, + { + id: "spec:consumers.spec-studio.sharing", + specKind: "behavior", + altitude: "story", + readiness: "scoped", + title: "A Studio build is stamped and shared from a static host", + narrative: null, + sections: { + 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: + "The original stamps the Studio's data with the git commit and the build time; the Studio renders byte-identical files from one graph, and `spec:extraction.determinism` keeps wall-clock timestamps and run-specific values out of generated output. Where does the stamp live, if anywhere?", + blocking: true, + key: "buildStamp", + }, + ], + }, + behavior: { + rules: [ + "A build is shared by uploading its page set to a static host, such as S3, Vercel or GitHub Pages.", + "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.", + ], + }, + design: { + dataStamp: + "each data file carries the graph's `schemaVersion`, the git commit SHA it was built from or `unknown` for a local build, and the build timestamp.", + previewPath: "a pull request's preview is served at `/preview//`.", + }, + }, + deliveryFacts: [], + file: "specs/consumers/spec-studio.sharing.sdp.md", + }, + { + id: "spec:consumers.spec-studio.shell", + specKind: "behavior", + altitude: "story", + readiness: "scoped", + title: "The Studio's shell frames every page with navigation, a rail and a canvas", + narrative: null, + sections: { + intent: { + outcome: + "Let a reader 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", + }, + { + 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", + }, + { + 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", + }, + ], + }, + behavior: { + rules: [ + "Every page has the same shell: a top navigation, a left rail and a main canvas.", + "Search is persistent on every page; each result shows the Spec's readiness and opens its page.", + "The rail's count of findings is the count of the validation report's findings, never a count the page derives.", + ], + }, + ui: { + description: "The parts of the shell, top to bottom and left to right.", + topNavigation: + "one bar with the entries Packs, Capabilities, Architecture, Tests, Evidence and Search; the first five open a lens, Packs and Capabilities being the two groupings of the Packs lens, and Search opens the search.", + leftRail: + "the current Pack's tree, its member Specs in the Pack's authored order, then the recent edits, then the count of validation findings by severity.", + mainCanvas: "the page of the Spec or Pack selected in the rail or in a lens.", + search: + "indexes every Spec's title, id, terms and notes, and lists the matches, each with its readiness and a link into its page.", + }, + }, + deliveryFacts: [], + file: "specs/consumers/spec-studio.shell.sdp.md", + }, + { + id: "spec:consumers.spec-studio.spec-page", + specKind: "behavior", + altitude: "story", + readiness: "scoped", + title: "A Spec page shows one Spec in collapsible panels, in a fixed order", + narrative: null, + sections: { + intent: { + outcome: + "Show everything the graph holds about one Spec on one page, in panels a reader 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", + }, + { + 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", + }, + { + question: + "The original shows each open question's owner and the date it was added; an open question carries its text, whether it blocks and an optional key, and git records when its line was added. Does the panel show an owner or a date, and from where?", + blocking: false, + key: "questionMetadata", + }, + { + question: + "The original's runtime panel binds a Fastify route to the Effect program it invokes, as `api:POST:/orders` invokes an Effect program with `R = CreateOrderUseCase`; shows the Effect layer `layer:CreateOrderUseCaseLive`, which provides `port:CreateOrderUseCase`, requires `port:OrderRepository` and `port:EventBus`, has the lifetime `scoped` and the test layer `layer:CreateOrderUseCaseTest`; names the external system `external:postgres`; and, for Awilix, binds the registration `createOrderUseCase` to `impl:CreateOrderUseCase` as `asClass CreateOrderUseCase` with the lifetime `SCOPED`, depending on `impl:OrderRepository` and `impl:EventBus`. An anchor carries identity only, and `component` and `uses` are its only structural fields. Does the 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", + }, + ], + }, + 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 node a panel names is a link to its page.", + "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.", + ], + }, + ui: { + description: "The panels in page order.", + header: + "the Spec's id, its stated readiness beside the floor reached, its altitude and kind; its title; the Packs it belongs to; and its warning and error counts as pill badges.", + readinessColors: + "each rung has its own color, from a pinned palette that stays distinct under color blindness.", + intentPanel: + "the intent section as prose, then an open-questions panel headed with their count, each question with its text, whether it blocks, its key and the address the key gives it, and an action that composes intent scoped to that question.", + behaviorPanel: + "the rules, then the examples as 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 open their pages and show their source file and line on hover; below it, the `design` entries, then the decision records the Spec names by `decidedBy` with each decision's 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: + "the implementation, verifier and oracle bindings, each anchor's file and line as a link, with an action that opens the file at that line in the reader's editor.", + verificationPanel: + "the verifiers and the coverage of the example space, laid out as `spec:consumers.spec-studio.verification-panels` states.", + evidencePanel: + "builds, deployments, runtime observations and the SBOM, laid out as `spec:consumers.spec-studio.verification-panels` states.", + 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.", + }, + }, + deliveryFacts: [], + file: "specs/consumers/spec-studio.spec-page.sdp.md", + }, + { + id: "spec:consumers.spec-studio.verification-panels", + specKind: "behavior", + altitude: "story", + readiness: "scoped", + title: "The panels for examples, targets, verifiers and evidence", + narrative: null, + 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", + }, + { + question: + "The original reads coverage from a harness's combinations; a parent's example space types its slots and the space contract lists each example's bound point, but only a slot typed as a union of literals has a finite set of values, and harnesses are outside this Pack. Does the grid enumerate those slots only?", + blocking: false, + key: "coverageSource", + }, + ], + }, + 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.", + "The evidence panel shows only runtime evidence the graph records, and reads not tracked while no delivery fact records an observation.", + "Each action on these panels composes scoped intent and changes no source.", + ], + }, + ui: { + description: "The panels' parts in page order.", + ruleList: "each rule on its own line, followed by the example cards.", + exampleCard: + "one card per example: its title; its stated readiness and whether it is an Executable Spec; its given, when and then steps with their bound values; and actions to view its verifying test, open the example, and compose intent to edit it.", + 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 the last measured value next to it; 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 file and line as a link.", + coverageGrid: + "when the Spec owns an example space, one row per combination of slot values, marked covered when an example's bound point witnesses it and missing otherwise, with an action that composes intent proposing examples for the missing rows.", + evidencePanel: + "the last build with its SLSA attestation, the last deployment with its environment, the OpenTelemetry observation against the target with a link to the tool that holds it, and the CycloneDX SBOM to download.", + }, + }, + deliveryFacts: [], + file: "specs/consumers/spec-studio.verification-panels.sdp.md", + }, ] as const; diff --git a/test/self-hosting-oracle/decisions.ts b/test/self-hosting-oracle/decisions.ts index be4a16e7..3e9dd8e0 100644 --- a/test/self-hosting-oracle/decisions.ts +++ b/test/self-hosting-oracle/decisions.ts @@ -1035,7 +1035,7 @@ export const decisionsSpecs = [ sections: { intent: { outcome: - "Make a Spec id written in prose resolve, and make one keyed entry of an open section addressable, so a reference below the Spec level can be checked.", + "Make a Spec id written in prose resolve, and make one keyed entry of an open section or one keyed open question addressable, so a reference below the Spec level can be checked.", openQuestions: [ { question: @@ -1048,10 +1048,11 @@ export const decisionsSpecs = [ context: "Specs name other Specs, and single entries of them, in prose. Referential integrity reads edges and Pack references only. A prose id that does not resolve passes validation, a mention that no relation backs hides a dependent from blast radius, and finding every Spec that repeats an entry is a text search. The first adopter corpus carried its design as keyed entries, and most of its prose mentions had no declared relation.", decision: - "A Spec id in narrative or section text, outside `gwt` and `gwt-vocabulary` fences, is a mention. A mention that does not resolve is a conformance error. A mention of another Spec that shares no declared relation with the mentioning Spec, in either direction, is one warning per mentioning Spec and target Spec pair, whatever the number of locations; an address on the mentioning Spec's own id is checked for its entry and never warned. An entry address is a Spec id followed by `#`, the section name `design` or `ui`, a dot, and the entry's lower-camel ASCII key, as in `spec:#design.`; it addresses one keyed entry of that Spec and resolves when the Spec exists and that section holds the key as its own key, other than `description`. The `#` sub-part is reserved for the entry address in every namespace: a Spec's own id, a relation target, a Pack member or model reference, an anchor id or target, and the `specId` and `ref` builders all refuse it. A mention mints no edge, and the reader gains no join.", + "A Spec id in narrative or section text, outside `gwt` and `gwt-vocabulary` fences, is a mention. A mention that does not resolve is a conformance error. A mention of another Spec that shares no declared relation with the mentioning Spec, in either direction, is one warning per mentioning Spec and target Spec pair, whatever the number of locations; an address on the mentioning Spec's own id is checked for its entry and never warned. An entry address is a Spec id followed by `#`, the section name `design`, `ui`, or `question`, a dot, and the entry's lower-camel ASCII key, as in `spec:#design.` or `spec:#question.`. A `design` or `ui` address resolves when the Spec exists and that section holds the key as its own key, other than `description`; a `question` address resolves when the Spec exists and one of its open questions carries that key. The `#` sub-part is reserved for the entry address in every namespace: a Spec's own id, a relation target, a Pack member or model reference, an anchor id or target, and the `specId` and `ref` builders all refuse it. A mention mints no edge, and the reader gains no join.", rationale: [ "The warning asks for a relation that already exists, so linkage keeps one home. A relation in either direction backs a mention because blast radius walks its one hop both ways, so a reverse edge already surfaces the dependent. One warning per pair keeps the report readable where a Spec names one neighbor in many bullets, and the mention audit keeps every location.", "This applies the content-only sections ruling rather than contradicting it. A mention mints no edge, so no consumer branches on a reference union, and the warning names exactly the double-linkage drift that ruling calls legal and silent today. The id grammar already parses and formats a `#` sub-part; this record gives it its only meaning, and the dot inside the sub-part is the one grammar change.", + "The owner ruled that an open question may carry an optional key in its marker, as in `- [blocking #aggregateReach] text`, addressed as `spec:#question.aggregateReach`. The key grammar is the Design key grammar, a key is unique among one Spec's open questions, and an unkeyed question stays lawful. A register that cites a question by its position breaks silently when a question above it moves or closes, and the first adopter's register had drifted in 47 of 151 such citations; a key breaks loudly where it is renamed.", "The owner ruled that the address carries the section, because open-section keys are unique within a section and not within a Spec, so `spec:#design.shared` and `spec:#ui.shared` stay distinct when one carrier holds both; that `#` is refused in every identity, because neither this corpus nor the first adopter's carries one, re-measured as zero in both at `242d8e6`, and one meaning per sub-part keeps resolution a lookup instead of a precedence rule; and that one lower-camel key grammar serves every carrier, so an address is typeable from memory and a lawful TypeScript key outside it is simply unaddressable.", ], alternatives: [ @@ -1063,9 +1064,9 @@ export const decisionsSpecs = [ "Warning only on a missing forward relation would turn a parent's mention of its own child into a finding the child's `refines` edge already answers. Refused by the owner.", ], consequences: [ - "Only a keyed entry of `design` or `ui` is addressable. A Workflow flow, a Rule or Contract bullet, a Model term, and an open question have no address; a reference to one of them stays a Spec-level mention, and a sequence that must be addressed is authored as keyed entries.", - "The key `description` names a section's leading prose and is never an entry, so an address ending in `.description` never resolves. A TypeScript key outside the lower-camel ASCII grammar is lawful and has no address.", - "A renamed open-section key fails validation in every Spec that addresses it.", + "Only a keyed entry of `design` or `ui` and a keyed open question are addressable. A Workflow flow, a Rule or Contract bullet, a Model term, and an unkeyed open question have no address; a reference to one of them stays a Spec-level mention, and a sequence that must be addressed is authored as keyed entries.", + "The key `description` names the leading prose of a `design` or `ui` section and is never an entry there, so a `design` or `ui` address ending in `.description` never resolves. A question key is a value on the question, not a property beside section prose, so `description` is an ordinary question key. A TypeScript key outside the lower-camel ASCII grammar is lawful and has no address.", + "A renamed or removed open-section key or question key fails validation in every Spec that addresses it.", "Blast radius and delta review scope include a mentioning Spec once a declared relation backs its mention.", "A family pattern written in id form reads as an unresolved mention, so such a pattern is written as plain words.", "The id contract and its test move together: the stable-ids round-trip example re-binds from a sectionless `#` sub-part to an entry address, and the id tests move the sectionless form to the refused list.", @@ -1079,7 +1080,7 @@ export const decisionsSpecs = [ id: "spec:decisions.authored-entry-order", specKind: "decision", altitude: "feature", - readiness: "scoped", + readiness: "ready", file: "specs/decisions/authored-entry-order.sdp.md", title: "The Design Review renders open-section entries as a list", narrative: null, @@ -1087,13 +1088,6 @@ export const decisionsSpecs = [ intent: { outcome: "Render the entries of an open section as a readable list in authored order, so a signature or a step reads as text and not as an escaped JSON string.", - openQuestions: [ - { - question: - "This record changes a stated rule of the Design Review, so on ratification it declares `supersedes` on the shipped-projections freeze for open-section rendering and must pass the ADR three-part test. The envelope declares `dependsOn` until then, so a draft never reads as a landed supersession in the graph. Does the owner reopen the freeze now, or after `spec:extraction.open-section-order` has landed and the ordered JSON has been read?", - blocking: true, - }, - ], }, decision: { context: @@ -1115,4 +1109,124 @@ export const decisionsSpecs = [ }, deliveryFacts: [], }, + { + id: "spec:decisions.question-key-rendering", + specKind: "decision", + altitude: "feature", + readiness: "ready", + title: "The Design Review shows an open question's key", + narrative: null, + sections: { + intent: { + outcome: + "Show an open question's key on its Spec page, so a reader can cite the question's entry address from the page.", + }, + decision: { + context: + "`spec:model.open-question-keys` gives an open question an optional key in its marker and an entry address `spec:#question.`. The Design Review renders an open question as its escaped text, with the word blocking after a blocking one, and the shipped projections are frozen, so a reader of the page cannot see the key the address needs. The first adopter cites its open questions from a decision register and from generated pages.", + decision: + "The Design Review renders an open question that carries a key with the key as inline code, `#`, at the start of its item, before the question's text; a question without a key renders as before. The freeze on the four shipped projections stands for everything else.", + rationale: [ + "The key is the one part of the address the page did not show, and the `#` that opens it in the marker opens it on the page.", + ], + alternatives: [ + "Rendering the whole address beside each question repeats the Spec id on every question of its own page.", + "Leaving the key off the page keeps the freeze whole and leaves the address to the carrier file and recipe 20.", + ], + consequences: [ + "A self-hosted page whose Spec keys a question changes; the checkout-v1 goldens hold no keyed question and do not change.", + ], + }, + }, + deliveryFacts: [], + file: "specs/decisions/question-key-rendering.sdp.md", + }, + { + id: "spec:decisions.studio-html-surface", + specKind: "decision", + altitude: "feature", + readiness: "scoped", + title: "The Studio renders HTML, and the HTML stays derived", + narrative: null, + sections: { + intent: { + outcome: + "Record why the Studio's surface is generated HTML rather than Markdown, and what keeps the HTML out of the diffs a reviewer reads.", + openQuestions: [ + { + question: + "Is the Studio's HTML the primary surface for a person reviewing a design, with Markdown as the fallback, as the original states, or one human projection beside the generated Markdown Design Review, which the consumers concept keeps as the MVP view after agents, the first sink?", + blocking: true, + key: "primarySurface", + }, + { + question: + "The original accepts HTML at about two to four times Markdown's generation time, with a negligible token cost under prompt caching and million-token windows, which prices a page a model writes; a projection is rendered by code from the graph, so does the argument become a bound on page weight or build time, or fall away?", + blocking: false, + key: "generationCost", + }, + { + question: + "The original keeps git diffs over canonical TypeScript Specs; the carrier ruling makes Markdown the default carrier and the TypeScript DSL a lawful per-ID option. Does any part of the case for derived HTML depend on the carrier being TypeScript?", + blocking: false, + key: "canonicalCarrier", + }, + ], + }, + decision: { + context: + "When a model writes the Specs, a person's main act is review, and review reads tables, diagrams, code, comparisons and examples across many Specs. Markdown caps out at about a hundred lines, past which nobody reads it, its authors included; large Specs are common, and Markdown rots into a wall of text quickly.", + decision: + "The Studio renders generated HTML as its surface. The HTML is derived and never committed in raw form: git diffs stay over the canonical Spec source, and the Studio's files are regenerated from the graph.", + rationale: [ + "HTML conveys more per screen: tables, SVG diagrams, code blocks with syntax highlighting, embedded harnesses, interactive sliders, side-by-side comparisons and tabbed views are all native to it.", + "HTML is shareable: a build uploaded to S3 or any static host is a link, with no rendering-engine mismatch between viewers.", + "HTML is interactive: a reader can move an NFR target on a slider, toggle a scenario harness, or reorder a Pack's members by drag and drop, none of which Markdown can do.", + "When a model writes the Specs, review is the person's main act, and HTML beats Markdown for review by a wide margin.", + "The download is acceptable: HTML takes about two to four times Markdown's generation time, and with prompt caching and million-token windows the token cost is negligible for the value gained.", + ], + alternatives: [ + "Markdown as the primary surface diffs cleanly in git, but caps out at about a hundred lines and cannot be interactive.", + ], + consequences: [ + "The principal trade-off of HTML, diffs that are noisier in git, is sidestepped because the HTML is derived and never committed.", + "Markdown remains a fallback surface.", + ], + }, + }, + deliveryFacts: [], + file: "specs/decisions/studio-html-surface.sdp.md", + }, + { + id: "spec:decisions.studio-web-components", + specKind: "decision", + altitude: "feature", + readiness: "scoped", + title: "The Studio is built from custom elements, not a framework", + narrative: null, + sections: { + intent: { + outcome: + "Record why the Studio's building blocks are Web Components rather than components of a framework.", + }, + decision: { + context: + "The Studio's views must work inside the Studio, standalone in any HTML container, and embedded in status reports, pull request descriptions and slide exports, and its assets should stay small and cache well.", + decision: + "The Studio is built from Web Components, custom elements written without React, Vue, Svelte or another framework.", + rationale: [ + "Custom elements carry no framework lock-in and work standalone in any HTML container.", + "The Studio's assets stay small and cache-friendly.", + ], + alternatives: [ + "Components of React, Vue or Svelte would impose that framework on every page that embeds one of them.", + ], + consequences: [ + "The few elements that need richer state, the harness and the scenario editor, may use a small reactive primitive inside themselves, such as `@lit/reactive-element` or a signals-style atom, without imposing a framework on the page that embeds them.", + ], + }, + }, + deliveryFacts: [], + file: "specs/decisions/studio-web-components.sdp.md", + }, ] as const; diff --git a/test/self-hosting-oracle/declared-relations.ts b/test/self-hosting-oracle/declared-relations.ts index 800fd183..7e556ec7 100644 --- a/test/self-hosting-oracle/declared-relations.ts +++ b/test/self-hosting-oracle/declared-relations.ts @@ -748,7 +748,6 @@ export const expectedDeclaredRelations = [ ["spec:validation.prose-mentions", "refines", "spec:validation.referential-integrity"], ["spec:validation.prose-mentions", "decidedBy", "spec:decisions.checked-mentions"], ["spec:decisions.authored-entry-order", "refines", "spec:consumers.design-review"], - ["spec:decisions.authored-entry-order", "dependsOn", "spec:decisions.shipped-projections-frozen"], ["spec:decisions.authored-entry-order", "dependsOn", "spec:extraction.open-section-order"], ["spec:extraction.open-section-order", "refines", "spec:extraction.derive-graph"], ["spec:extraction.contract-declarations", "refines", "spec:extraction.executable-contracts"], @@ -974,4 +973,155 @@ export const expectedDeclaredRelations = [ ], ["spec:consumers.adopter-on-ramp", "dependsOn", "spec:consumers.agent-surface.register-recipes"], ["spec:consumers.adopter-on-ramp", "dependsOn", "spec:consumers.shipped-protocol-corpus"], + [ + "spec:consumers.agent-surface.address-and-cycle-recipes", + "refines", + "spec:consumers.agent-surface.register-recipes", + ], + [ + "spec:consumers.agent-surface.address-and-cycle-recipes", + "dependsOn", + "spec:decisions.checked-mentions", + ], + ["spec:consumers.spec-studio.components", "refines", "spec:consumers.spec-studio"], + ["spec:consumers.spec-studio.components", "dependsOn", "spec:consumers.spec-studio.data"], + ["spec:consumers.spec-studio.components", "decidedBy", "spec:decisions.studio-web-components"], + ["spec:consumers.spec-studio.data", "refines", "spec:consumers.spec-studio"], + ["spec:consumers.spec-studio.data", "dependsOn", "spec:consumers.reader"], + ["spec:consumers.spec-studio.data", "dependsOn", "spec:extraction.pack-member-order"], + ["spec:consumers.spec-studio.data", "dependsOn", "spec:validation.next-rung-floor"], + ["spec:consumers.spec-studio.data", "constrainedBy", "spec:extraction.determinism"], + ["spec:consumers.spec-studio.intent-panel", "refines", "spec:consumers.intent-composition"], + ["spec:consumers.spec-studio.intent-panel", "dependsOn", "spec:consumers.edit-model"], + ["spec:consumers.spec-studio.intent-panel", "dependsOn", "spec:model.open-question-keys"], + ["spec:consumers.spec-studio.lenses", "refines", "spec:consumers.spec-studio"], + ["spec:consumers.spec-studio.lenses", "dependsOn", "spec:consumers.spec-studio.components"], + ["spec:consumers.spec-studio.lenses", "dependsOn", "spec:extraction.pack-member-order"], + ["spec:consumers.spec-studio.lenses", "dependsOn", "spec:observation.runtime-overlay"], + ["spec:consumers.spec-studio.lenses", "decidedBy", "spec:decisions.binding-not-liveness"], + [ + "spec:consumers.spec-studio.lenses", + "decidedBy", + "spec:decisions.architectural-significance-rides-primitives", + ], + ["spec:consumers.spec-studio.responsive", "refines", "spec:consumers.spec-studio"], + ["spec:consumers.spec-studio.responsive", "dependsOn", "spec:extraction.claim-taxonomy"], + ["spec:consumers.spec-studio", "refines", "spec:consumers.projections-model"], + ["spec:consumers.spec-studio", "dependsOn", "spec:consumers.reader"], + ["spec:consumers.spec-studio", "dependsOn", "spec:consumers.edit-model"], + ["spec:consumers.spec-studio", "constrainedBy", "spec:extraction.determinism"], + ["spec:consumers.spec-studio", "decidedBy", "spec:decisions.studio-html-surface"], + ["spec:consumers.spec-studio", "decidedBy", "spec:decisions.studio-web-components"], + ["spec:consumers.spec-studio", "decidedBy", "spec:decisions.shipped-projections-frozen"], + ["spec:consumers.spec-studio.sharing", "refines", "spec:consumers.spec-studio"], + ["spec:consumers.spec-studio.sharing", "dependsOn", "spec:extraction.schema-versioning"], + ["spec:consumers.spec-studio.sharing", "constrainedBy", "spec:extraction.determinism"], + ["spec:consumers.spec-studio.shell", "refines", "spec:consumers.spec-studio"], + ["spec:consumers.spec-studio.shell", "dependsOn", "spec:consumers.spec-studio.data"], + ["spec:consumers.spec-studio.spec-page", "refines", "spec:consumers.spec-studio"], + ["spec:consumers.spec-studio.spec-page", "dependsOn", "spec:consumers.spec-studio.data"], + ["spec:consumers.spec-studio.spec-page", "dependsOn", "spec:consumers.derived-readiness-banner"], + ["spec:consumers.spec-studio.spec-page", "dependsOn", "spec:consumers.binding-language-views"], + ["spec:consumers.spec-studio.spec-page", "dependsOn", "spec:model.open-question-keys"], + ["spec:consumers.spec-studio.spec-page", "decidedBy", "spec:decisions.authored-entry-order"], + [ + "spec:consumers.spec-studio.verification-panels", + "refines", + "spec:consumers.spec-studio.spec-page", + ], + [ + "spec:consumers.spec-studio.verification-panels", + "dependsOn", + "spec:extraction.executable-contracts", + ], + [ + "spec:consumers.spec-studio.verification-panels", + "dependsOn", + "spec:observation.runtime-overlay", + ], + [ + "spec:consumers.spec-studio.verification-panels", + "decidedBy", + "spec:decisions.binding-not-liveness", + ], + [ + "spec:consumers.spec-studio.verification-panels", + "decidedBy", + "spec:decisions.verification-posture-not-realization", + ], + [ + "spec:decisions.authored-entry-order", + "supersedes", + "spec:decisions.shipped-projections-frozen", + ], + ["spec:decisions.question-key-rendering", "refines", "spec:consumers.design-review"], + ["spec:decisions.question-key-rendering", "dependsOn", "spec:model.open-question-keys"], + [ + "spec:decisions.question-key-rendering", + "supersedes", + "spec:decisions.shipped-projections-frozen", + ], + ["spec:decisions.studio-html-surface", "refines", "spec:consumers.spec-studio"], + ["spec:decisions.studio-html-surface", "dependsOn", "spec:extraction.regenerability"], + ["spec:decisions.studio-web-components", "refines", "spec:consumers.spec-studio"], + [ + "spec:extraction.pack-member-order.manifest-order-kept", + "refines", + "spec:extraction.pack-member-order", + ], + [ + "spec:extraction.pack-member-order.manifest-order-kept", + "verifies", + "spec:extraction.pack-member-order", + ], + ["spec:extraction.pack-member-order", "refines", "spec:extraction.derive-graph"], + ["spec:extraction.pack-member-order", "dependsOn", "spec:carrier.markdown-pack-authoring"], + ["spec:extraction.pack-member-order", "constrainedBy", "spec:extraction.determinism"], + [ + "spec:model.open-question-keys.keyed-question-resolves", + "refines", + "spec:model.open-question-keys", + ], + [ + "spec:model.open-question-keys.keyed-question-resolves", + "verifies", + "spec:model.open-question-keys", + ], + ["spec:model.open-question-keys.renamed-key-breaks", "refines", "spec:model.open-question-keys"], + ["spec:model.open-question-keys.renamed-key-breaks", "verifies", "spec:model.open-question-keys"], + [ + "spec:model.open-question-keys.repeated-key-refused", + "refines", + "spec:model.open-question-keys", + ], + [ + "spec:model.open-question-keys.repeated-key-refused", + "verifies", + "spec:model.open-question-keys", + ], + ["spec:model.open-question-keys", "refines", "spec:model.spec-sections"], + ["spec:model.open-question-keys", "dependsOn", "spec:carrier.markdown-body-grammar"], + ["spec:model.open-question-keys", "decidedBy", "spec:decisions.checked-mentions"], + [ + "spec:validation.next-rung-floor.blocking-question", + "refines", + "spec:validation.next-rung-floor", + ], + [ + "spec:validation.next-rung-floor.blocking-question", + "verifies", + "spec:validation.next-rung-floor", + ], + ["spec:validation.next-rung-floor", "refines", "spec:validation.readiness-floor"], + ["spec:validation.next-rung-floor", "dependsOn", "spec:validation.typed-dependency-floor"], + [ + "spec:validation.next-rung-floor.typed-dependency-targets", + "refines", + "spec:validation.next-rung-floor", + ], + [ + "spec:validation.next-rung-floor.typed-dependency-targets", + "verifies", + "spec:validation.next-rung-floor", + ], ] as const; diff --git a/test/self-hosting-oracle/extraction.ts b/test/self-hosting-oracle/extraction.ts index 72094ce2..61c438ce 100644 --- a/test/self-hosting-oracle/extraction.ts +++ b/test/self-hosting-oracle/extraction.ts @@ -24,6 +24,7 @@ export const extractionSpecs = [ "Delivery facts are computed node facts: a resolving `satisfies` edge contributes `implemented`, and an enabled direct verifier contributes `has-verifier` only to its target.", "Inferred structural edges are advisory inputs to impact analysis and never become authoritative graph truth.", "The key order of `design`, `ui`, and `model.terms` is the authored order and part of the graph contract.", + "A Pack node carries its manifest's members in authored order, and the `belongsTo` edges that re-express them keep the global edge sort.", ], }, design: { @@ -292,7 +293,7 @@ export const extractionSpecs = [ 'a graph derived from the authored spec {specId: "spec:probe.schema-versioning"}', ], when: ["the graph payload is serialized"], - then: ['the payload declares the schema version {schemaVersion: "0.6.0"}'], + then: ['the payload declares the schema version {schemaVersion: "0.7.0"}'], }, ], }, @@ -708,13 +709,13 @@ export const extractionSpecs = [ }, behavior: { rules: [ - "The entry order of `design`, `ui`, and `model` terms is authored content. Extraction keeps it, the serialized graph emits it, the Design Review's fenced JSON follows it for open sections, and the Model table follows it for terms.", + "The entry order of `design`, `ui`, and `model` terms is authored content. Extraction keeps it, the serialized graph emits it, the Design Review's entry list follows it for open sections, and the Model table follows it for terms.", "Authored order is a function of the committed source, so two derivations of one commit stay byte-identical. What is given up is invariance under permutation: two carriers that author the same entries in different orders derive different graph bytes, and parity for these sections means the same entries in the same order.", 'A key is integer-like when it matches `^(0|[1-9][0-9]*)$`. The Markdown carrier refuses an integer-like Model term with the structure finding `model terms must not be integer-like` and drops the carrier, as it does for every structure refusal; its open-section keys are lower-camel and cannot be integer-like. The TypeScript carrier reads each name under `design`, `ui`, or `model.terms` as the key JavaScript gives it. A name written as an identifier or a string is its text, and a numeric name is the string form of its number, so `1e3` is `"1000"`, `0x10` is `"16"`, and `0.0000001` is `"1e-7"`. A property, method, getter, or setter whose key is integer-like is refused with the finding `extract/unrecognized-property` at severity error, where that finding is otherwise a warning, and the message `property "" is refused: integer-like keys are not accepted in design, ui, or model terms`. Each refused member gets one finding whatever its value, and only that member drops; the Spec and its other keys stay. A numeric name whose key is not integer-like, such as `1.5`, drops as a non-static name, as it did before this rule. The Gherkin carrier has no open section. The owner ruled the refusal over an ordered-list representation; re-measured at `242d8e6`, neither this corpus nor the first adopter\'s carries such a key.', "With integer-like keys refused, a plain object keeps insertion order, so the representation stays an object and no field shape changes. Authored key order is part of the graph contract. The derived graph establishes it, so the reader, the serialized graph, and the Design Review agree. In `design` and `ui` the section's own `description`, its leading prose, comes first, then every other key in authored order. `model.terms` keeps pure authored order, and a term named `description` is an ordinary term, because the Model section's prose lives at `model.description`.", "The schema version moves from `0.5.0` to `0.6.0`, documented as a change of accepted ids, since the `#` sub-part becomes an entry address, and of ordering semantics, with unchanged field shapes. The serialized graph changes for every Spec whose entries are not already in code-unit order.", "The reader exposes authored order as the key order of a Spec context's `sections.design`, `sections.ui`, and `sections.model.terms`; entry search rows follow it, and no reader method is added.", - "This rule is an ordinary revision, and no Spec stated the sort it replaced. `canonicalDynamicSection` in `src/extract/serialize.ts`, `renderDynamicRecord` in `src/projections/design-review-markdown.ts`, and `renderModel` in `src/projections/design-review-section-content.ts` are the sites that keep authored order. The Design Review's encoding rule is untouched; rendering entries as a list is `spec:decisions.authored-entry-order`.", + "This rule is an ordinary revision, and no Spec stated the sort it replaced. `canonicalDynamicSection` in `src/extract/serialize.ts`, `renderDynamicRecord` in `src/projections/design-review-markdown.ts`, and `renderModel` in `src/projections/design-review-section-content.ts` are the sites that keep authored order. Rendering entries as a list is a change to the Design Review's encoding rule, ruled by `spec:decisions.authored-entry-order`; this rule supplies only the order the list follows.", ], }, }, @@ -766,4 +767,78 @@ export const extractionSpecs = [ }, deliveryFacts: [], }, + { + id: "spec:extraction.pack-member-order.manifest-order-kept", + specKind: "example", + altitude: "story", + readiness: "defined", + title: "A manifest's member order survives into the graph and the reader", + narrative: null, + sections: { + behavior: { + examples: [ + { + given: [ + 'a Markdown Pack manifest whose specs list reads {manifestOrder: "spec:probe.zeta, spec:probe.alpha, spec:probe.mid"}', + ], + when: ["the graph is derived and serialized"], + then: [ + 'the serialized Pack node lists the members {serializedMembers: "spec:probe.zeta, spec:probe.alpha, spec:probe.mid"}', + 'the serialized belongsTo edges run from {edgeSources: "spec:probe.alpha, spec:probe.mid, spec:probe.zeta"}', + 'the reader\'s Pack context lists the members {readerMembers: "spec:probe.zeta, spec:probe.alpha, spec:probe.mid"}', + 'the payload declares the schema version {schemaVersion: "0.7.0"}', + ], + }, + ], + }, + intent: { + outcome: + "Execute the member order rule on a manifest whose authored order is not id order.", + assumptions: [ + "The world writes one Markdown Pack manifest and three member Specs in one extraction root. Each member is a story-altitude behavior Spec stating `idea` with an Intent outcome and no relations. Lists are written as ids joined by a comma and a space.", + ], + }, + }, + deliveryFacts: ["has-verifier"], + file: "specs/extraction/pack-member-order.manifest-order-kept.sdp.md", + }, + { + id: "spec:extraction.pack-member-order", + specKind: "rule", + altitude: "story", + readiness: "defined", + title: "A Pack node keeps its members in authored order", + narrative: null, + sections: { + intent: { + problem: + "A Pack manifest lists its members in the order its author chose to read them, and the graph loses that order: the serialized edges sort by from, type, and to, and the reader's Pack context sorts members by id, so an adopter reads the manifest file to recover a reading order the graph was given.", + outcome: + "Carry a Pack's members in the manifest's authored order through the serialized graph, the reader's Pack context, and the Design Review's Pack page, while the edges keep their global sort.", + }, + behavior: { + rules: [ + "A Pack node carries `members`, the manifest's `specs` list in authored order, a repeat included where the carrier admits one. The `belongsTo` edges re-express the same list, one edge per entry, so the members of a Pack node and the sources of its `belongsTo` edges are one list read twice and never disagree in a derived graph.", + "`members` is always present on a Pack node, and an empty manifest list derives `members: []`. The serialized Pack node writes its fields in the order `id`, `nodeType`, `claim`, `title`, `framing`, `file`, `members`, `modelRefs`, omitting an absent optional field.", + "Nodes keep their sort by id and edges keep their sort by from, type, and to. Authored member order is content of the Pack node, as authored entry order is content of a Spec's open sections, so two derivations of one commit stay byte-identical and two manifests that list the same members in different orders derive different Pack nodes.", + "The reader's Pack context lists one member row per entry of `members`, in that order, and its verifier gaps follow the same order. The Design Review's Pack page renders its member table and its verifier gap list in that order.", + "The schema version moves from `0.6.0` to `0.7.0`, documented as a field added to the Pack node and an optional field added to an open question, with node and edge order unchanged. One bump covers both additions.", + "A repeated member is kept only where the carrier admits it. The TypeScript carrier carries a repeated member into `members` and the reader's rows as the manifest wrote it, and the repetition stays the pack-coherence error it is today, counted on the `belongsTo` edges. The Markdown carrier refuses a repeated member in a Pack manifest before derivation with `extract/invalid-frontmatter` and the message `duplicate entry in specs`, as it does today.", + "The realizing sites are `derivePackNode` and the membership loop in `src/extract/derive.ts`, the Pack case of `canonicalNode` in `src/extract/serialize.ts`, and `packContext` in `src/reader/reader.ts`.", + ], + exampleSpace: { + given: ["a Markdown Pack manifest whose specs list reads {manifestOrder:string}"], + when: ["the graph is derived and serialized"], + then: [ + "the serialized Pack node lists the members {serializedMembers:string}", + "the serialized belongsTo edges run from {edgeSources:string}", + "the reader's Pack context lists the members {readerMembers:string}", + "the payload declares the schema version {schemaVersion:string}", + ], + }, + }, + }, + deliveryFacts: ["implemented", "has-verifier"], + file: "specs/extraction/pack-member-order.sdp.md", + }, ] as const; diff --git a/test/self-hosting-oracle/index.ts b/test/self-hosting-oracle/index.ts index fc2baf9e..7bffd674 100644 --- a/test/self-hosting-oracle/index.ts +++ b/test/self-hosting-oracle/index.ts @@ -145,7 +145,7 @@ export const expectedWarnings = [ export { expectedAnchors } from "./anchors.js"; export { expectedDeclaredRelations } from "./declared-relations.js"; -export { expectedPackMembers } from "./pack-members.js"; +export { expectedPackMembers, expectedStudioPackMembers, expectedPacks } from "./pack-members.js"; export { acceptedArchitecturalUnits, coarseGrainCoverage, diff --git a/test/self-hosting-oracle/model.ts b/test/self-hosting-oracle/model.ts index ef8c9928..fdfc141e 100644 --- a/test/self-hosting-oracle/model.ts +++ b/test/self-hosting-oracle/model.ts @@ -264,7 +264,7 @@ export const modelSpecs = [ when: ["the identifier is parsed"], then: [ 'parsing {outcome: "is refused"}', - 'the refusal names the reason {reason: "entry address must be
. with section design or ui"}', + 'the refusal names the reason {reason: "entry address must be
. with section design, ui, or question"}', ], }, ], @@ -438,4 +438,154 @@ export const modelSpecs = [ }, deliveryFacts: [], }, + { + id: "spec:model.open-question-keys.keyed-question-resolves", + specKind: "example", + altitude: "story", + readiness: "defined", + title: "A keyed question is reified and its address resolves", + narrative: null, + sections: { + behavior: { + examples: [ + { + given: [ + 'a Markdown Spec carrier {specId: "spec:probe.subject"} whose open questions read {questions: "- [blocking #aggregateReach] Does the owner widen the aggregate?; - [non-blocking] Is the name final?"}', + 'a second Spec that names {address: "spec:probe.subject#question.aggregateReach"} in its narrative and declares dependsOn on the first', + ], + when: ["the extractor reifies both carriers and the graph is validated"], + then: [ + "the first carrier is reified: {reified: true}", + 'the extraction findings name {extractMessage: ""}', + 'the graph holds the question keys {keys: "aggregateReach"}', + "the report holds {mentionErrors: 0} prose-mention errors", + ], + }, + ], + }, + intent: { + outcome: + "Execute the key grammar and the question address on one keyed question beside an unkeyed one.", + assumptions: [ + "The world writes two Markdown carriers in one extraction root. The first is a story-altitude rule Spec stating `scoped` with an Intent outcome, one rule, and one declared `refines` on the second; its Open questions hold the entries written in `questions`, one line each, where the slot joins them with a semicolon and a space. The second is a story-altitude rule Spec stating `idea` with an Intent outcome and one rule; it names the address in its narrative and declares `dependsOn` on the first. The messages of the extraction findings are joined by a semicolon and a space, and an empty string names no finding; the keys of every Spec in the graph are joined by a comma and a space.", + ], + }, + }, + deliveryFacts: ["has-verifier"], + file: "specs/model/open-question-keys.keyed-question-resolves.sdp.md", + }, + { + id: "spec:model.open-question-keys.renamed-key-breaks", + specKind: "example", + altitude: "story", + readiness: "defined", + title: "A renamed question key fails the mention that addresses it", + narrative: null, + sections: { + behavior: { + examples: [ + { + given: [ + 'a Markdown Spec carrier {specId: "spec:probe.subject"} whose open questions read {questions: "- [blocking #aggregateScope] Does the owner widen the aggregate?"}', + 'a second Spec that names {address: "spec:probe.subject#question.aggregateReach"} in its narrative and declares dependsOn on the first', + ], + when: ["the extractor reifies both carriers and the graph is validated"], + then: [ + "the first carrier is reified: {reified: true}", + 'the extraction findings name {extractMessage: ""}', + 'the graph holds the question keys {keys: "aggregateScope"}', + "the report holds {mentionErrors: 1} prose-mention errors", + ], + }, + ], + }, + intent: { + outcome: + "Execute the missing-entry error on an address whose question key the target no longer carries.", + assumptions: [ + "The world is the one of the keyed-question example, with the key renamed in the first carrier and the address in the second left as it was.", + ], + }, + }, + deliveryFacts: ["has-verifier"], + file: "specs/model/open-question-keys.renamed-key-breaks.sdp.md", + }, + { + id: "spec:model.open-question-keys.repeated-key-refused", + specKind: "example", + altitude: "story", + readiness: "defined", + title: "A repeated question key refuses its carrier", + narrative: null, + sections: { + behavior: { + examples: [ + { + given: [ + 'a Markdown Spec carrier {specId: "spec:probe.subject"} whose open questions read {questions: "- [blocking #aggregateReach] Does the owner widen the aggregate?; - [non-blocking #aggregateReach] Does the aggregate keep its name?"}', + 'a second Spec that names {address: "spec:probe.subject#question.aggregateReach"} in its narrative and declares dependsOn on the first', + ], + when: ["the extractor reifies both carriers and the graph is validated"], + then: [ + "the first carrier is reified: {reified: false}", + 'the extraction findings name {extractMessage: "open question keys must be unique"}', + 'the graph holds the question keys {keys: ""}', + "the report holds {mentionErrors: 1} prose-mention errors", + ], + }, + ], + }, + intent: { + outcome: + "Execute the uniqueness refusal, and show that the address then names a missing Spec.", + assumptions: [ + "The world is the one of the keyed-question example, with two questions in the first carrier that carry the same key. The first carrier is refused whole, so the graph holds no question key and the address's Spec is absent.", + ], + }, + }, + deliveryFacts: ["has-verifier"], + file: "specs/model/open-question-keys.repeated-key-refused.sdp.md", + }, + { + id: "spec:model.open-question-keys", + specKind: "rule", + altitude: "story", + readiness: "defined", + title: "An open question may carry a key that addresses it", + narrative: null, + sections: { + intent: { + problem: + "An open question has no identity but its position and its text, so a register or a decision that cites one breaks silently when a question above it moves, closes, or is reworded.", + outcome: + "Let an author give an open question an optional key, so `spec:#question.` names it, resolves while it stands, and fails validation where it is renamed or removed.", + }, + behavior: { + rules: [ + "An open question is prose, or an object with `question`, an optional `blocking` flag, and an optional `key`. A key matches `^[a-z][A-Za-z0-9]*$`, the grammar of a Design key, and is unique among the open questions of one Spec. An unkeyed question stays lawful and has no address.", + "In the Markdown carrier the key follows the flag inside the marker, after one space and `#`, as in `- [blocking #aggregateReach] Does the owner widen the aggregate?` or `- [non-blocking #pageHome] Where does the page live?`. A marker whose key text is empty or off the grammar is refused with `open question keys must be lower-camel ASCII`, and a key that repeats an earlier key of the same Spec is refused with `open question keys must be unique`, each as the structure finding `extract/invalid-markdown-structure` at the line of the entry, and the carrier is refused whole as for every structure refusal.", + 'In the TypeScript carrier the key is the `key` property of the question object. A key that is not a string on the grammar, or that repeats an earlier key of the same Spec, is refused with the finding `extract/unrecognized-property` at severity error and the message `property "intent.openQuestions[].key" is refused: open question keys must be lower-camel ASCII` or `property "intent.openQuestions[].key" is refused: open question keys must be unique`, with `` the zero-based position; only the key drops, and the question and the Spec stay.', + "The serialized graph writes a question object's fields in the order `question`, `blocking`, `key`, omitting an absent one. The import emitter writes a keyed question's marker as `[ #]`, so a keyed TypeScript question round-trips into Markdown.", + "The entry address gains the section `question`: `spec:#question.` parses, is refused in every id slot as every entry address is, and resolves when the Spec exists and one of its open questions carries that key. A mention whose question key no open question carries is the prose-mentions error for a missing entry, so renaming or removing a key fails validation in every Spec that addresses it.", + "The open-question register reports each question's key, or null for an unkeyed question. The mention audit and entry search read the `question` address as they read a `design` or `ui` address, and entry search gives a keyed question's text the address `spec:#question.`.", + "The realizing sites are `IntentOpenQuestion` in `src/model/sections.ts`, the open-question marker in `src/extract/markdown-body-owner-behavior.ts`, the question shape in `src/extract/reify.ts`, `canonicalOpenQuestion` in `src/extract/serialize.ts`, the Intent emission in `src/import/emit-markdown.ts`, the entry-address grammar in `src/ids.ts`, and `hasAddressedEntry` in `src/validate/validators.ts`.", + ], + exampleSpace: { + given: [ + "a Markdown Spec carrier {specId:string} whose open questions read {questions:string}", + "a second Spec that names {address:string} in its narrative and declares dependsOn on the first", + ], + when: ["the extractor reifies both carriers and the graph is validated"], + then: [ + "the first carrier is reified: {reified:boolean}", + "the extraction findings name {extractMessage:string}", + "the graph holds the question keys {keys:string}", + "the report holds {mentionErrors:number} prose-mention errors", + ], + }, + }, + }, + deliveryFacts: ["implemented", "has-verifier"], + file: "specs/model/open-question-keys.sdp.md", + }, ] as const; diff --git a/test/self-hosting-oracle/pack-members.ts b/test/self-hosting-oracle/pack-members.ts index c9263161..d563deaf 100644 --- a/test/self-hosting-oracle/pack-members.ts +++ b/test/self-hosting-oracle/pack-members.ts @@ -204,4 +204,65 @@ export const expectedPackMembers = [ "spec:validation.typed-dependency-floor.missing-targets", "spec:validation.typed-dependency-floor.unsettled-fact", "spec:validation.typed-dependency-floor.stated-readiness", + "spec:extraction.pack-member-order", + "spec:extraction.pack-member-order.manifest-order-kept", + "spec:consumers.agent-surface.address-and-cycle-recipes", + "spec:validation.next-rung-floor", + "spec:validation.next-rung-floor.typed-dependency-targets", + "spec:validation.next-rung-floor.blocking-question", + "spec:model.open-question-keys", + "spec:model.open-question-keys.keyed-question-resolves", + "spec:model.open-question-keys.renamed-key-breaks", + "spec:model.open-question-keys.repeated-key-refused", + "spec:decisions.question-key-rendering", + "spec:consumers.spec-studio", + "spec:decisions.studio-html-surface", + "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.verification-panels", + "spec:consumers.spec-studio.intent-panel", + "spec:consumers.spec-studio.components", + "spec:decisions.studio-web-components", + "spec:consumers.spec-studio.responsive", + "spec:consumers.spec-studio.sharing", +] as const; + +export const expectedStudioPackMembers = [ + "spec:consumers.spec-studio", + "spec:decisions.studio-html-surface", + "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.verification-panels", + "spec:consumers.spec-studio.intent-panel", + "spec:consumers.spec-studio.components", + "spec:decisions.studio-web-components", + "spec:consumers.spec-studio.responsive", + "spec:consumers.spec-studio.sharing", +] as const; +export const expectedPacks = [ + { + id: "pack:spec-studio-v1", + nodeType: "Pack", + 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. No member is built. A member reaches built code through the realized Spec it relates to where one exists; `spec:consumers.spec-studio.shell`, `spec:consumers.spec-studio.components` and `spec:decisions.studio-web-components` have none yet.", + file: "specs/consumers/spec-studio.pack.sdp.md", + members: expectedStudioPackMembers, + modelRefs: ["spec:consumers.projections-model"], + }, + { + id: "pack:self-hosting-v1", + nodeType: "Pack", + claim: "declared", + title: "Self-hosting", + framing: "The Protocol authors and validates its own delivery model.", + file: "specs/self-hosting.pack.sdp.md", + members: expectedPackMembers, + modelRefs: ["spec:model.protocol-domain", "spec:model.core-model"], + }, ] as const; diff --git a/test/self-hosting-oracle/structural-edges.ts b/test/self-hosting-oracle/structural-edges.ts index d2c5dbfa..157f7df1 100644 --- a/test/self-hosting-oracle/structural-edges.ts +++ b/test/self-hosting-oracle/structural-edges.ts @@ -30,6 +30,7 @@ export const structuralMembershipExceptions = [ "impl:protocol.shipped-protocol-corpus", "impl:protocol.authoring-recipes", "impl:protocol.delivery-session-on-ramp", + "impl:protocol.address-and-cycle-recipes", ] as const; export const acceptedArchitecturalUnits = [ @@ -443,6 +444,21 @@ export const acceptedArchitecturalUnits = [ anchorId: "impl:protocol.validation-contracts", componentId: "component:protocol.validate", }, + { + unit: "src/extract/derive.ts#derivePackNode", + anchorId: "impl:protocol.pack-member-order", + componentId: "component:protocol.extract", + }, + { + unit: "src/extract/markdown-body-owner-behavior.ts#OPEN_QUESTION_MARKER", + anchorId: "impl:protocol.open-question-keys", + componentId: "component:protocol.extract", + }, + { + unit: "src/validate/readiness-floor.ts#evaluateReadinessFloor", + anchorId: "impl:protocol.next-rung-floor", + componentId: "component:protocol.validate", + }, ] as const; export const expectedMemberOfEdges = acceptedArchitecturalUnits.map((row) => [ diff --git a/test/self-hosting-oracle/validation.ts b/test/self-hosting-oracle/validation.ts index 7d5e5b62..e80adff6 100644 --- a/test/self-hosting-oracle/validation.ts +++ b/test/self-hosting-oracle/validation.ts @@ -34,7 +34,7 @@ export const validationSpecs = [ "The anchor clause reads the bindings that are present, so a Spec carrying no anchor clears it — the floor never demands a binding an author has not made.", "Only relations the Spec itself declares count toward the relation clauses; membership of a Pack is derived from the manifest and never stands in for an authored relation.", "Every clause stated here is kind-blind. The two evidence clauses are the one kind-conditional place in the floor, and what counts as a kind's natural evidence is stated in full by the refining Spec that carries the per-kind evidence table.", - "One clause table serves both readings: it checks the readiness an author states, and it yields derived readiness — the highest rung whose cumulative clauses all pass — which is read beside the stated rung and never overwrites it.", + "One clause table serves both readings: it checks the readiness an author states, and it yields derived readiness — the highest rung whose cumulative clauses all pass — which is read beside the stated rung and never overwrites it, and it answers for a target rung an evaluation names, which yields the next rung's unmet clauses.", "The floor is the mechanism while the specific clause thresholds are one chosen representation, so a team-overridable floor configuration is a designed-for deferral rather than a landed capability: no validator reads a per-team floor setting, and the shipped clause table is the only floor any Spec is checked against.", "The floor table in `src/validate/readiness-floor.ts` is the clause set's code-level source of truth and the realizing entrypoint. The clauses stated here and the rows of that table are one law read twice, so any disagreement between them is drift to resolve on one side, never a second floor.", ], @@ -790,6 +790,7 @@ export const validationSpecs = [ rules: [ "Pack membership must not repeat a Spec, and every modelRef must resolve to a model-kind Spec.", "Membership is counted on the derived belongsTo edges the manifest re-expresses, so a repeated manifest entry is named once per repeated member.", + "A Pack's members list and its belongsTo edges agree: every listed member has one belongsTo edge into the Pack, and every belongsTo edge into the Pack comes from a listed member. A listed member with no edge, a listed member that is no Spec and no edge carries, and an edge from an unlisted source are each one error, in authored order, so the Pack context, a Spec's packs, and blast radius read the same membership. An edge whose source is absent or no Spec stays referential integrity's or the edge contract's finding, never a second one here.", "There is no duplicated-intent check on a Pack: a Pack states no system truth to duplicate, and semantic overlap among its members remains human or agent review rather than validator judgment. This lets a coherent group contain low-detail Specs without turning grouping into implementation demand.", "The realizing validator entrypoint is `checkPackCoherence` in `src/validate/validators.ts`.", ], @@ -1181,7 +1182,7 @@ export const validationSpecs = [ "The clause stays kind-blind and reads resolving targets only. An unresolved target remains the relation-resolution clause's failure, never a second one.", "`verifies` and `supersedes` stay outside the clause. A verifier's rung is independent of the Spec it verifies, and a replacement decision does not rest on the record it supersedes.", "An unsettled fact is stated the way any unsettled truth is. A constraint Spec that records a blocking open question clears at most `scoped`. When it states a rung below `defined`, a Spec bounded by it may state `defined` if its own floor clears, but cannot lawfully state `ready`.", - "The floor keeps one target clause, `typed-dependency-targets-are-defined`, in `src/validate/readiness-floor.ts`. Its parent `spec:validation.readiness-floor` states the same target bound.", + "The floor keeps one target clause, `typed-dependency-targets-are-defined`, in `src/validate/readiness-floor.ts`. Its parent `spec:validation.readiness-floor` states the same target bound. A failure of the clause names every target that breaks it.", ], exampleSpace: { given: [ @@ -1251,7 +1252,7 @@ export const validationSpecs = [ "A mention starts at `spec:` where the character before it, if any, is not an ASCII letter, an ASCII digit, or `-`. A backslash before ASCII punctuation is read as that punctuation, as Markdown reads an escape. The token ends at ASCII whitespace, at one of the ASCII delimiters `` ` `` `\"` `'` `(` `)` `[` `]` `{` `}` `<` `>` `|`, or at a character outside ASCII that is whitespace, U+0085 included, punctuation other than connector punctuation, a symbol, or a space, line, or paragraph separator, so every dash, ellipsis, typographic quote, and arrow ends it. Every other character stays in the token, letters, combining marks, digits, connector punctuation, control characters other than whitespace, and format, private-use, and unassigned characters among them. Trailing `.` `,` `;` `:` `!` `?` `*` `_` `~` are then removed after the prefix, so a sentence, a list, or emphasis may end in an id. When nothing remains after `spec:`, there is no mention, so a bare prefix or a placeholder such as `spec:` stays prose. The whole token goes to `parseId`, so an underscore, a slash, a comma, a control character, or any character outside ASCII that stays in it refuses the whole token as malformed; the token is never cut short to the valid id before that character, and a refused token counts toward no pair. A token equal to the scanning Spec's own id is skipped.", 'A token that fails `parseId` is an error with the message `Mention "" in "" at is not a Spec id or entry address: `, where `` is the grammar\'s refusal text with its `Invalid ID "": ` prefix stripped.', 'A token whose Spec part names no Primitive node is an error with the message `Mention in "" at points to missing target "".`, followed by the referential-integrity nearest-id suggestion when one is unique.', - 'A token whose Spec exists and whose entry address names no key of that section, or names `description`, is an error with the message `Mention in "" at points to missing entry "
." of "".`', + 'A token whose Spec exists and whose entry address names no key of that `design` or `ui` section, names `description` there, or names a question key no open question of that Spec carries, is an error with the message `Mention in "" at points to missing entry "
." of "".`', "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.`.", '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 "" in "" at locations, first at , 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 `` the count of distinct paths, written `1 location` when it is one, and `` 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.', "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.", @@ -1482,4 +1483,117 @@ export const validationSpecs = [ }, deliveryFacts: ["has-verifier"], }, + { + id: "spec:validation.next-rung-floor.blocking-question", + specKind: "example", + altitude: "story", + readiness: "defined", + title: "A scoped Spec held by a blocking question is told the clause", + narrative: null, + sections: { + behavior: { + examples: [ + { + given: [ + 'the graph holds a rule spec {specId: "spec:probe.held"} stating readiness {statedReadiness: "scoped"}', + 'the spec {structure: "records a blocking open question"}', + ], + when: ["the reader builds the spec's context"], + then: [ + 'the floor reached is {floorReached: "scoped"} and the next rung is {nextRung: "defined"}', + 'the next rung\'s first unmet clause is {clauseId: "no-blocking-open-questions"}', + 'that failure names the targets {targets: ""}', + "the stated rung's floor failures number {currentFailures: 0}", + ], + }, + ], + }, + intent: { + outcome: + "Execute the next rung's evidence on a Spec whose `defined` floor fails on a blocking open question, where the failure names no target.", + assumptions: [ + "The world holds two story-altitude rule Specs with an Intent outcome and one rule each. The subject declares one `refines` on the other, its parent, which states `ready`, and records one blocking open question. No anchor is present. A failure with no targets is written as an empty string.", + ], + }, + }, + deliveryFacts: ["has-verifier"], + file: "specs/validation/next-rung-floor.blocking-question.sdp.md", + }, + { + id: "spec:validation.next-rung-floor", + specKind: "rule", + altitude: "story", + readiness: "defined", + title: "The floor names what blocks the next rung", + narrative: null, + sections: { + intent: { + problem: + "The floor is evaluated only at the stated rung, so promotion preflight answers no unmet clause for a Spec that states `defined` and cannot state `ready`, and an author or an adopter's page walks the floor table one rung up with its own code. A failed typed-dependency clause names the clause and not the dependencies that break it, so the same page recomputes which targets stand below `defined`.", + outcome: + "Let the one evaluator answer for any rung, name the next rung's unmet clauses in the reader, and name the targets that fail the typed-dependency clause, so no consumer evaluates the floor a second way.", + }, + behavior: { + rules: [ + "The evaluator takes an optional target rung. Given one, it evaluates the cumulative clauses of every rung up to and including that rung; without one, it evaluates up to the stated rung, as before. A Spec of an unratified kind, or an evaluation with no ratified rung to reach, yields no failure, and the descriptor conformance error owns that case.", + "The next rung is the rung above derived readiness: `idea` when no rung derives, and none when derived readiness is `ready`. Because derived readiness is the highest rung whose cumulative clauses all pass, the failures at the next rung are that rung's own clauses, and there is at least one whenever a next rung exists for a Spec of a ratified kind.", + "The reader's Spec context carries the next rung's failures beside the stated rung's failures, computed by the same evaluator, and an empty list when there is no next rung. This is a field on the existing per-Spec context, not a new reader method, and it meets the second-caller bar: promotion preflight and the first adopter's Pack page both need it, and the page's own walk read the rung above the stated one where preflight reads the rung above the floor reached.", + "A failure of the typed-dependency clause carries its targets: every distinct resolving `refines`, `dependsOn`, `constrainedBy`, or `decidedBy` target that states a rung below `defined`, or that is not a Spec, each with its relation type, its id, and its stated rung when it is a Spec. Targets sort by relation type in that order and then by id in code-unit order. No other clause carries targets.", + "Promotion preflight keeps every field it reports and adds the next rung's failures and the first of them, each failure with its targets when it has them. The next rung's first unmet clause is a report and never a promotion: stating any rung stays a human's edit.", + "The realizing sites are `evaluateReadinessFloor` and the typed-dependency clause in `src/validate/readiness-floor.ts`, and `specContext` in `src/reader/reader.ts`.", + ], + exampleSpace: { + given: [ + 'the graph holds a rule spec {specId:string} stating readiness {statedReadiness:"scoped"|"defined"}', + 'the spec {structure:"depends on a spec stating scoped"|"records a blocking open question"}', + ], + when: ["the reader builds the spec's context"], + then: [ + 'the floor reached is {floorReached:"scoped"|"defined"} and the next rung is {nextRung:"defined"|"ready"}', + "the next rung's first unmet clause is {clauseId:string}", + "that failure names the targets {targets:string}", + "the stated rung's floor failures number {currentFailures:number}", + ], + }, + }, + }, + deliveryFacts: ["implemented", "has-verifier"], + file: "specs/validation/next-rung-floor.sdp.md", + }, + { + id: "spec:validation.next-rung-floor.typed-dependency-targets", + specKind: "example", + altitude: "story", + readiness: "defined", + title: "A defined Spec resting on a scoped dependency is told which one", + narrative: null, + sections: { + behavior: { + examples: [ + { + given: [ + 'the graph holds a rule spec {specId: "spec:probe.subject"} stating readiness {statedReadiness: "defined"}', + 'the spec {structure: "depends on a spec stating scoped"}', + ], + when: ["the reader builds the spec's context"], + then: [ + 'the floor reached is {floorReached: "defined"} and the next rung is {nextRung: "ready"}', + 'the next rung\'s first unmet clause is {clauseId: "typed-dependency-targets-are-defined"}', + 'that failure names the targets {targets: "dependsOn spec:probe.basis (scoped)"}', + "the stated rung's floor failures number {currentFailures: 0}", + ], + }, + ], + }, + intent: { + outcome: + "Execute the next rung's evidence on a Spec whose own floor clears `defined` and whose `ready` floor fails on one dependency.", + assumptions: [ + "The world holds three story-altitude rule Specs with an Intent outcome and one rule each. The subject declares `dependsOn` on the basis Spec named in the targets, which states `scoped` and declares `refines` on a third Spec stating `idea`. No Spec records an open question and no anchor is present. A target is written as its relation type, its id, and its stated rung in parentheses.", + ], + }, + }, + deliveryFacts: ["has-verifier"], + file: "specs/validation/next-rung-floor.typed-dependency-targets.sdp.md", + }, ] as const; diff --git a/test/self-hosting-pack-member-order.test.ts b/test/self-hosting-pack-member-order.test.ts new file mode 100644 index 00000000..8005831b --- /dev/null +++ b/test/self-hosting-pack-member-order.test.ts @@ -0,0 +1,208 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ref, specTest, testAnchorId } from "@libar-dev/software-delivery-protocol"; +import { unspecified } from "@libar-dev/software-delivery-protocol/runner"; +import { manifestOrderKeptContract } from "../generated/contracts/extraction.pack-member-order.manifest-order-kept.contract.js"; +import type { + PackMemberOrderConditions, + PackMemberOrderOutcome, +} from "../generated/contracts/extraction.pack-member-order.space.js"; +import { + createReader, + extract, + renderDesignReview, + serializeGraph, + validateGraph, +} from "../src/index.js"; +import type { GraphSchema, PackContext, PackNode } from "../src/index.js"; +import { deriveGraph } from "../src/extract/derive.js"; +import { paramsForStep } from "./helpers/generated-contract.js"; +import { registerManifestOrderKept } from "./extraction.pack-member-order.manifest-order-kept.test.generated.js"; + +const PACK_ID = "pack:probe.order"; +const MEMBERS_STEP = "the serialized Pack node lists the members {serializedMembers}"; +const EDGES_STEP = "the serialized belongsTo edges run from {edgeSources}"; +const READER_STEP = "the reader's Pack context lists the members {readerMembers}"; +const VERSION_STEP = "the payload declares the schema version {schemaVersion}"; +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +interface World { + readonly root: string; + readonly members: readonly string[]; + graph?: GraphSchema; + context?: PackContext; + pack?: PackNode; + page?: string; +} + +function createWorld(point: Partial): World { + const root = mkdtempSync(join(tmpdir(), "sdp-pack-order-")); + roots.push(root); + const members = point.manifestOrder?.split(", ") ?? []; + writeFileSync( + join(root, "order.pack.sdp.md"), + `---\nid: ${PACK_ID}\nspecs: ${JSON.stringify(members)}\n---\n# Probe member order\n`, + ); + for (const id of new Set(members)) { + writeFileSync( + join(root, `${id.slice("spec:".length)}.sdp.md`), + `---\nid: ${id}\nkind: behavior\naltitude: story\nreadiness: idea\nrelations: {}\n---\n# ${id}\n\n## Intent\n- outcome: Keep manifest member order.\n`, + ); + } + return { root, members }; +} + +function invoke(world: World): void { + const result = extract({ root: world.root }); + const serialized = serializeGraph(result.graph); + world.graph = JSON.parse(serialized) as GraphSchema; + world.pack = world.graph.nodes.find((node): node is PackNode => node.nodeType === "Pack"); + const reader = createReader(world.graph); + world.context = reader.packContext(PACK_ID); + world.page = renderDesignReview(reader).find( + (page) => page.path === "pack/probe.order.md", + )?.content; + expect(serializeGraph(extract({ root: world.root }).graph)).toBe(serialized); + expect( + validateGraph(result.graph).findings.filter((finding) => finding.severity === "error"), + ).toHaveLength(world.members.length === new Set(world.members).size ? 0 : 1); +} + +function observe(world: World): PackMemberOrderOutcome { + return { kind: MEMBERS_STEP, serializedMembers: world.pack?.members.join(", ") ?? "" }; +} + +function expected(point: Partial): PackMemberOrderOutcome { + return point.manifestOrder === undefined + ? unspecified + : { kind: MEMBERS_STEP, serializedMembers: point.manifestOrder }; +} + +function assertOrder(world: World): void { + expect(world.pack?.members).toEqual(world.members); + expect(world.context?.members.map((member) => member.id)).toEqual(world.members); + expect(world.context?.verifierGaps.map((gap) => gap.id)).toEqual(world.members); + const page = world.page ?? ""; + const tableIds = [...page.matchAll(/^\| \[`(spec:[^`]+)`\]/gm)].map((match) => match[1]); + const gapIds = [...page.matchAll(/^- \[`(spec:[^`]+)`\]/gm)].map((match) => match[1]); + expect(tableIds).toEqual(world.members); + expect(gapIds).toEqual(world.members); + expect(world.graph?.nodes.map((node) => node.id)).toEqual( + [PACK_ID, ...new Set(world.members)].sort(), + ); + expect(world.graph?.edges.map((edge) => edge.from)).toEqual([...world.members].sort()); + expect(Object.keys(world.pack ?? {})).toEqual([ + "id", + "nodeType", + "claim", + "title", + "file", + "members", + ]); +} + +const packMemberOrderTestAnchor = specTest({ + id: testAnchorId("test:protocol.pack-member-order"), + label: "verifies manifest order in the graph, reader and Pack page", + verifies: ref("spec:extraction.pack-member-order.manifest-order-kept"), +}); +void packMemberOrderTestAnchor; +registerManifestOrderKept({ + createWorld, + invoke, + observe, + expected, + assertions: (world) => { + assertOrder(world); + expect(world.pack?.members.join(", ")).toBe( + paramsForStep(manifestOrderKeptContract, MEMBERS_STEP).serializedMembers, + ); + expect(world.graph?.edges.map((edge) => edge.from).join(", ")).toBe( + paramsForStep(manifestOrderKeptContract, EDGES_STEP).edgeSources, + ); + expect(world.context?.members.map((member) => member.id).join(", ")).toBe( + paramsForStep(manifestOrderKeptContract, READER_STEP).readerMembers, + ); + expect(world.graph?.schemaVersion).toBe( + paramsForStep(manifestOrderKeptContract, VERSION_STEP).schemaVersion, + ); + }, +}); + +describe("Pack member order", () => { + it("keeps repeated members in the graph, reader, member table and gap list", () => { + const world = createWorld({ + manifestOrder: "spec:probe.zeta, spec:probe.alpha, spec:probe.zeta, spec:probe.mid", + }); + // Markdown refuses duplicate manifest entries before graph derivation. The TS carrier + // lets the graph's existing pack-coherence check report the repeated belongsTo edge. + rmSync(join(world.root, "order.pack.sdp.md")); + writeFileSync( + join(world.root, "order.pack.sdp.ts"), + `import { pack, packId, ref } from "@libar-dev/software-delivery-protocol"; +export const order = pack({ id: packId("${PACK_ID}"), title: "Probe member order", specs: [${world.members.map((id) => `ref("${id}")`).join(", ")}] }); +`, + ); + invoke(world); + assertOrder(world); + expect(validateGraph(extract({ root: world.root }).graph).findings).toMatchObject([ + { + validatorId: "conformance/pack-coherence", + severity: "error", + subjectId: PACK_ID, + relatedId: "spec:probe.zeta", + }, + ]); + }); + + it("carries an empty members list for an empty manifest", () => { + const world = createWorld({}); + invoke(world); + assertOrder(world); + }); + + it.each([undefined, "not an array"])("derives empty members when specs is %s", (specs) => { + const graph = deriveGraph( + [], + [{ id: PACK_ID, file: "order.pack.sdp.md", line: 1, data: { specs } }], + [], + ); + expect(graph.nodes).toMatchObject([{ id: PACK_ID, members: [] }]); + }); + + it("serializes framing, file, members and modelRefs in the ruled field order", () => { + const graph = deriveGraph( + [], + [ + { + id: PACK_ID, + file: "order.pack.sdp.md", + line: 1, + data: { + title: "Order", + framing: "Read in order.", + specs: ["spec:probe.zeta", "spec:probe.alpha"], + modelRefs: ["spec:probe.alpha"], + }, + }, + ], + [], + ); + const pack = (JSON.parse(serializeGraph(graph)) as GraphSchema).nodes[0]; + expect(Object.keys(pack ?? {})).toEqual([ + "id", + "nodeType", + "claim", + "title", + "framing", + "file", + "members", + "modelRefs", + ]); + }); +}); diff --git a/test/self-hosting-typed-dependency.test.ts b/test/self-hosting-typed-dependency.test.ts index 38096002..3b01f0ef 100644 --- a/test/self-hosting-typed-dependency.test.ts +++ b/test/self-hosting-typed-dependency.test.ts @@ -12,7 +12,14 @@ import type { TypedDependencyFloorOutcome, } from "../generated/contracts/validation.typed-dependency-floor.space.js"; import { createReader, schemaVersion } from "../src/index.js"; -import type { GraphEdge, GraphNode, PrimitiveNode, SpecKind, SpecReadiness } from "../src/index.js"; +import type { + GraphEdge, + GraphNode, + PrimitiveNode, + ReadinessFloorTarget, + SpecKind, + SpecReadiness, +} from "../src/index.js"; import { registerRelationRungs } from "./validation.typed-dependency-floor.relation-rungs.test.generated.js"; import { registerMissingTargets } from "./validation.typed-dependency-floor.missing-targets.test.generated.js"; import { registerUnsettledFact } from "./validation.typed-dependency-floor.unsettled-fact.test.generated.js"; @@ -69,6 +76,8 @@ interface Trace { targetFails: boolean; missing: boolean; derivedTargetFails: boolean; + /** The targets a failure of the target clause names, in the order it names them. */ + failingTargets: ReadinessFloorTarget[]; } interface World { nodes: GraphNode[]; @@ -148,10 +157,15 @@ function addTrace( world.edges.push({ from: target, to: subject, type: "dependsOn", claim: "declared" }); } world.edges.push({ from: subject, to: target, type: relation, claim: "declared" }); + const breaks = !options.missing && dependencies.includes(relation) && rungs.indexOf(rung) < 2; world.traces.push({ subject, target, missing: options.missing ?? false, + failingTargets: + breaks && relation !== "verifies" && relation !== "supersedes" + ? [{ type: relation, id: target, statedReadiness: rung }] + : [], derivedTargetFails: !options.missing && dependencies.includes(relation) && rungs.indexOf(rung) < 2, targetFails: @@ -188,6 +202,7 @@ function addMixedTrace( { from: target, to: trace.subject, type: "dependsOn", claim: "declared" }, { from: trace.subject, to: target, type: second, claim: "declared" }, ); + if (!firstFails) trace.failingTargets = [{ type: second, id: target, statedReadiness: "scoped" }]; trace.targetFails = true; trace.derivedTargetFails = true; } @@ -251,6 +266,17 @@ function observe(world: World): TypedDependencyFloorOutcome { expect(reader.specContext(trace.subject)?.derivedReadiness).toBe( trace.missing || trace.derivedTargetFails ? "defined" : "ready", ); + // A failure of the target clause names the targets that break it, at the stated rung and at + // the next rung; a missing target stays the resolution clause's and is never named. + const context = reader.specContext(trace.subject); + const targetsAt = (failures: readonly { clauseId: string; targets?: unknown }[] = []) => + failures.find((failure) => failure.clauseId === TARGET_CLAUSE)?.targets; + expect(targetsAt(context?.floorFailures)).toEqual( + trace.targetFails ? trace.failingTargets : undefined, + ); + expect(targetsAt(context?.nextRungFailures)).toEqual( + trace.derivedTargetFails ? trace.failingTargets : undefined, + ); const target = reader.specContext(trace.target); if (world.questions.includes(trace.target)) { expect(target?.derivedReadiness).toBe("scoped"); diff --git a/test/self-hosting-validators.test.ts b/test/self-hosting-validators.test.ts index 0c6464af..b3d15bbc 100644 --- a/test/self-hosting-validators.test.ts +++ b/test/self-hosting-validators.test.ts @@ -1241,6 +1241,7 @@ function createPackCoherenceWorld(point: Partial): Vali nodeType: "Pack", claim: "declared", title: "Probe aggregate", + members: Array.from({ length: memberCount }, () => specId), file: "specs/probe.pack.sdp.ts", }); diff --git a/test/skills.test.ts b/test/skills.test.ts index 30a320b5..923ce745 100644 --- a/test/skills.test.ts +++ b/test/skills.test.ts @@ -1,4 +1,5 @@ -import { lstatSync, readFileSync, readlinkSync } from "node:fs"; +import { lstatSync, mkdtempSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -6,10 +7,14 @@ import { buildGraphIndex, deriveReadiness, evaluateReadinessFloor, + extract, + graphValidatorIds, refines, spec, specId, + validateGraph, } from "../src/index.js"; +import type { PrimitiveNode } from "../src/index.js"; import { deriveFixtureGraph } from "./helpers/fixture-graph.js"; import { parseMarkdownBody } from "../src/extract/markdown-body.js"; @@ -346,6 +351,8 @@ describe("Protocol skill assets", () => { "twenty-two", "twenty-three", "twenty-four", + "twenty-five", + "twenty-six", ] as const; const countWord = countWords[headings.length]; @@ -722,3 +729,115 @@ describe("taught grammar agrees with parser behavior", () => { } }); }); + +/** + * The keyed open-question marker the authoring skill teaches, as one code span, and the address it + * says that marker has, read from the sentence that pairs them. + */ +function taughtQuestionMarker(): { readonly marker: string; readonly address: string } { + const text = readSkill(".agents/skills/sdp-authoring/SKILL.md").source; + const taught = /`([^`]+)` is\s+addressed as\s+`(spec:#question\.[^`]+)`/u.exec(text); + const marker = taught?.[1]; + const address = taught?.[2]; + if (marker === undefined || address === undefined) { + throw new Error("the authoring skill must pair one keyed marker with its question address"); + } + return { marker, address }; +} + +/** + * Writes the marker on one open question of a Spec in a temporary corpus, beside a second Spec + * that names the address and declares `dependsOn` on the first, then extracts and validates. + */ +function probeQuestionMarker(marker: string, addressTemplate: string) { + const subjectId = "spec:probe.taught"; + const address = addressTemplate.replace("spec:", subjectId); + const root = mkdtempSync(join(tmpdir(), "sdp-taught-marker-")); + try { + writeFileSync( + join(root, "taught.sdp.md"), + `--- +id: ${subjectId} +kind: rule +altitude: story +readiness: idea +relations: {} +--- +# Taught marker probe + +## Intent +- outcome: Carry the keyed question the authoring skill teaches. + +### Open questions +- ${marker} Does the owner widen the aggregate? + +## Rule +- The probe states one rule. +`, + ); + writeFileSync( + join(root, "citing.sdp.md"), + `--- +id: spec:probe.citing +kind: rule +altitude: story +readiness: idea +relations: + dependsOn: ${subjectId} +--- +# Citing probe + +The probe cites ${address} by its address. + +## Intent +- outcome: Name the taught question by its address. + +## Rule +- The probe states one rule. +`, + ); + const extraction = extract({ root }); + const subject = extraction.graph.nodes.find( + (node): node is PrimitiveNode => node.nodeType === "Primitive" && node.id === subjectId, + ); + const intent = (subject?.sections as Record | undefined)?.intent; + const questions = (intent as Record | undefined)?.openQuestions; + const first: unknown = Array.isArray(questions) ? questions[0] : undefined; + return { + address, + extractionErrors: extraction.report.findings.filter( + (finding) => finding.severity === "error", + ), + key: (first as Record | undefined)?.key, + mentionFindings: validateGraph(extraction.graph).findings.filter( + (finding) => finding.validatorId === graphValidatorIds.proseMentions, + ), + }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +describe("the taught question marker", () => { + it("extracts the key the skill's marker carries and resolves the address the skill gives it", () => { + const { marker, address } = taughtQuestionMarker(); + const probe = probeQuestionMarker(marker, address); + expect(probe.address).toBe(address.replace("spec:", "spec:probe.taught")); + expect(probe.extractionErrors).toEqual([]); + expect(probe.key).toBe(address.slice(address.indexOf("#question.") + "#question.".length)); + expect(probe.mentionFindings).toEqual([]); + }); + + it("leaves the address unresolved when the key stands outside the brackets", () => { + const { marker, address } = taughtQuestionMarker(); + const inverted = /^\[(\S+) (#\S+)\]$/u.exec(marker); + if (inverted?.[1] === undefined || inverted[2] === undefined) { + throw new Error("the taught marker must carry its flag and its key inside the brackets"); + } + const probe = probeQuestionMarker(`[${inverted[1]}] ${inverted[2]}`, address); + expect(probe.key).toBeUndefined(); + expect(probe.mentionFindings).toHaveLength(1); + expect(probe.mentionFindings[0]?.severity).toBe("error"); + expect(probe.mentionFindings[0]?.message).toContain("points to missing entry"); + }); +}); diff --git a/test/validation.next-rung-floor.blocking-question.test.generated.ts b/test/validation.next-rung-floor.blocking-question.test.generated.ts new file mode 100644 index 00000000..38030dd8 --- /dev/null +++ b/test/validation.next-rung-floor.blocking-question.test.generated.ts @@ -0,0 +1,81 @@ +// GENERATED by `sdp build` — the runnable registrar of spec:validation.next-rung-floor.blocking-question. +// 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 { blockingQuestionContract } from "../generated/contracts/validation.next-rung-floor.blocking-question.contract.js"; +import type { + Step, + StepParams, +} from "../generated/contracts/validation.next-rung-floor.blocking-question.contract.js"; +import type { + NextRungFloorConditions, + NextRungFloorOutcome, +} from "../generated/contracts/validation.next-rung-floor.space.js"; + +export function registerBlockingQuestion( + adapters: RunnableExampleAdapters, +): void { + type Execution = RunnableExampleWorld; + const bindings = { + "the graph holds a rule spec {specId} stating readiness {statedReadiness}": () => { + // Given state is materialized by createWorld from the generated point. + }, + "the spec {structure}": () => { + // Given state is materialized by createWorld from the generated point. + }, + "the reader builds the spec's context": async (execution) => { + await invokeRunnableExample(execution); + }, + "the floor reached is {floorReached} and the next rung is {nextRung}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the floor reached is {floorReached} and the next rung is {nextRung}", + params, + }); + }, + "the next rung's first unmet clause is {clauseId}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the next rung's first unmet clause is {clauseId}", + params, + }); + }, + "that failure names the targets {targets}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "that failure names the targets {targets}", + params, + }); + }, + "the stated rung's floor failures number {currentFailures}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the stated rung's floor failures number {currentFailures}", + params, + }); + }, + } satisfies StepBindings; + + registerRunnableExample( + blockingQuestionContract, + { + specId: "spec:probe.held", + statedReadiness: "scoped", + structure: "records a blocking open question", + }, + ["specId", "statedReadiness", "structure"], + adapters, + bindings, + ); +} diff --git a/test/validation.next-rung-floor.typed-dependency-targets.test.generated.ts b/test/validation.next-rung-floor.typed-dependency-targets.test.generated.ts new file mode 100644 index 00000000..285b9690 --- /dev/null +++ b/test/validation.next-rung-floor.typed-dependency-targets.test.generated.ts @@ -0,0 +1,81 @@ +// GENERATED by `sdp build` — the runnable registrar of spec:validation.next-rung-floor.typed-dependency-targets. +// 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 { typedDependencyTargetsContract } from "../generated/contracts/validation.next-rung-floor.typed-dependency-targets.contract.js"; +import type { + Step, + StepParams, +} from "../generated/contracts/validation.next-rung-floor.typed-dependency-targets.contract.js"; +import type { + NextRungFloorConditions, + NextRungFloorOutcome, +} from "../generated/contracts/validation.next-rung-floor.space.js"; + +export function registerTypedDependencyTargets( + adapters: RunnableExampleAdapters, +): void { + type Execution = RunnableExampleWorld; + const bindings = { + "the graph holds a rule spec {specId} stating readiness {statedReadiness}": () => { + // Given state is materialized by createWorld from the generated point. + }, + "the spec {structure}": () => { + // Given state is materialized by createWorld from the generated point. + }, + "the reader builds the spec's context": async (execution) => { + await invokeRunnableExample(execution); + }, + "the floor reached is {floorReached} and the next rung is {nextRung}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the floor reached is {floorReached} and the next rung is {nextRung}", + params, + }); + }, + "the next rung's first unmet clause is {clauseId}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the next rung's first unmet clause is {clauseId}", + params, + }); + }, + "that failure names the targets {targets}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "that failure names the targets {targets}", + params, + }); + }, + "the stated rung's floor failures number {currentFailures}": (execution, params) => { + compareContractOutcome(execution, { + kind: "then", + text: "the stated rung's floor failures number {currentFailures}", + params, + }); + }, + } satisfies StepBindings; + + registerRunnableExample( + typedDependencyTargetsContract, + { + specId: "spec:probe.subject", + statedReadiness: "defined", + structure: "depends on a spec stating scoped", + }, + ["specId", "statedReadiness", "structure"], + adapters, + bindings, + ); +} diff --git a/test/validators.test.ts b/test/validators.test.ts index 06df9f64..fcda5096 100644 --- a/test/validators.test.ts +++ b/test/validators.test.ts @@ -771,6 +771,205 @@ describe("graph validators", () => { }); }); +describe("conformance/pack-coherence — the members list and the belongsTo edges agree", () => { + const packId = "pack:probe.checkout"; + const specA = "spec:probe.a"; + const specB = "spec:probe.b"; + const codeId = "impl:probe.code"; + + function probePack(members: readonly string[]): GraphNode { + return { + id: packId, + nodeType: "Pack", + claim: "declared", + title: "Probe aggregate", + members, + file: "specs/probe.pack.sdp.ts", + }; + } + + function belongsTo(from: string): GraphEdge { + return { from, type: "belongsTo", to: packId, claim: "declared" }; + } + + function coherenceFindings(graph: GraphSchema): readonly Finding[] { + return validateGraph(graph).findings.filter( + (finding) => finding.validatorId === graphValidatorIds.packCoherence, + ); + } + + const probeSpecs = [ideaPrimitive(specA, "Probe A."), ideaPrimitive(specB, "Probe B.")]; + + it("reports nothing when every listed member has its one edge and every edge is listed", () => { + const graph = syntheticGraph( + [...probeSpecs, probePack([specA, specB])], + [belongsTo(specA), belongsTo(specB)], + ); + + expect(coherenceFindings(graph)).toEqual([]); + }); + + it("names a listed member with no belongsTo edge into the pack", () => { + const graph = syntheticGraph([...probeSpecs, probePack([specA, specB])], [belongsTo(specA)]); + + expect(coherenceFindings(graph)).toEqual([ + { + validatorId: graphValidatorIds.packCoherence, + family: "conformance", + severity: "error", + message: `Pack "${packId}" lists member "${specB}" with no belongsTo edge into the pack — the manifest and the derived edges must agree, or the reader answers membership differently per query.`, + subjectId: packId, + relatedId: specB, + file: "specs/probe.pack.sdp.ts", + }, + ]); + }); + + it("names a belongsTo edge whose source the members do not list", () => { + const graph = syntheticGraph( + [...probeSpecs, probePack([specA])], + [belongsTo(specA), belongsTo(specB)], + ); + + expect(coherenceFindings(graph)).toEqual([ + { + validatorId: graphValidatorIds.packCoherence, + family: "conformance", + severity: "error", + message: `Pack "${packId}" has a belongsTo edge from "${specB}", which its members do not list — the manifest and the derived edges must agree, or the reader answers membership differently per query.`, + subjectId: packId, + relatedId: specB, + file: "specs/probe.pack.sdp.ts", + }, + ]); + }); + + it("names a listed member that is no Spec when no edge carries it, and defers to the edge contract when one does", () => { + const codeNode: GraphNode = { + id: codeId, + nodeType: "CodeNode", + claim: "anchored", + file: "src/probe.ts", + line: 1, + }; + const unbound = syntheticGraph( + [...probeSpecs, codeNode, probePack([specA, codeId])], + [belongsTo(specA)], + ); + + expect(coherenceFindings(unbound)).toEqual([ + { + validatorId: graphValidatorIds.packCoherence, + family: "conformance", + severity: "error", + message: `Pack "${packId}" lists member "${codeId}", which is a CodeNode node, not a Spec — Pack members are Specs.`, + subjectId: packId, + relatedId: codeId, + file: "specs/probe.pack.sdp.ts", + }, + ]); + + // With an edge, the edge contract row already names the wrong-kind source: no second finding. + const bound = syntheticGraph( + [...probeSpecs, codeNode, probePack([specA, codeId])], + [belongsTo(specA), belongsTo(codeId)], + ); + const findings = validateGraph(bound).findings; + + expect(coherenceFindings(bound)).toEqual([]); + expect( + findings.some( + (finding) => + finding.validatorId === graphValidatorIds.claimSeparation && + finding.message.includes(`originates from a CodeNode node`), + ), + ).toBe(true); + }); + + it("leaves an absent listed member whose edge dangles to referential integrity", () => { + const graph = syntheticGraph( + [...probeSpecs, probePack([specA, "spec:probe.absent"])], + [belongsTo(specA), belongsTo("spec:probe.absent")], + ); + const findings = validateGraph(graph).findings; + + expect(coherenceFindings(graph)).toEqual([]); + expect( + findings.filter( + (finding) => + finding.validatorId === graphValidatorIds.referentialIntegrity && + finding.subjectId === "spec:probe.absent", + ), + ).toHaveLength(1); + }); + + it("leaves an unlisted belongsTo edge from an absent source to referential integrity", () => { + const graph = syntheticGraph( + [...probeSpecs, probePack([specA])], + [belongsTo(specA), belongsTo("spec:probe.absent")], + ); + const findings = validateGraph(graph).findings; + + expect(coherenceFindings(graph)).toEqual([]); + expect( + findings.filter( + (finding) => + finding.validatorId === graphValidatorIds.referentialIntegrity && + finding.subjectId === "spec:probe.absent", + ), + ).toHaveLength(1); + }); + + it("leaves an unlisted belongsTo edge from a CodeNode to the edge contract", () => { + const codeNode: GraphNode = { + id: codeId, + nodeType: "CodeNode", + claim: "anchored", + file: "src/probe.ts", + line: 1, + }; + const graph = syntheticGraph( + [...probeSpecs, codeNode, probePack([specA])], + [belongsTo(specA), belongsTo(codeId)], + ); + const findings = validateGraph(graph).findings; + + expect(coherenceFindings(graph)).toEqual([]); + expect( + findings.filter( + (finding) => + finding.validatorId === graphValidatorIds.claimSeparation && + finding.subjectId === codeId && + finding.message.includes("originates from a CodeNode node"), + ), + ).toHaveLength(1); + }); + + it("keeps the duplicate rule: a repeated member is named once, in authored order, with its count", () => { + const repeatedEdges = syntheticGraph( + [...probeSpecs, probePack([specB, specA, specB])], + [belongsTo(specB), belongsTo(specA), belongsTo(specB)], + ); + + expect( + coherenceFindings(repeatedEdges).map((finding) => [finding.relatedId, finding.message]), + ).toEqual([ + [ + specB, + `Pack "${packId}" lists member "${specB}" 2 times — membership is single-sourced on the manifest and duplicates are ambiguous (L2).`, + ], + ]); + + // A list that repeats a member its edges carry once is still one repeated member. + const repeatedList = syntheticGraph( + [...probeSpecs, probePack([specB, specA, specB])], + [belongsTo(specB), belongsTo(specA)], + ); + + expect(coherenceFindings(repeatedList).map((finding) => finding.relatedId)).toEqual([specB]); + }); +}); + describe("the models edge — the oracle anchor's contract row", () => { const modeled = spec({ id: specId("spec:orders.create-order"), @@ -1042,7 +1241,7 @@ describe("conformance/prose-mentions — prose mentions and their entry addresse "spec:probe.mentioning", "spec:x#foo", "design.step1", - 'Mention "spec:x#foo" in "spec:probe.mentioning" at design.step1 is not a Spec id or entry address: entry address must be
. with section design or ui', + 'Mention "spec:x#foo" in "spec:probe.mentioning" at design.step1 is not a Spec id or entry address: entry address must be
. with section design, ui, or question', ), ]); }); @@ -1155,6 +1354,147 @@ describe("conformance/prose-mentions — prose mentions and their entry addresse ]); }); + describe("a question address", () => { + function questionTarget(intent: Readonly>, design?: unknown) { + return mentionSpec("spec:probe.target", { + intent: { outcome: "Hold the questions.", ...intent }, + ...(design === undefined ? {} : { design }), + }); + } + + function questionFindings(target: PrimitiveNode, rule: string): readonly Finding[] { + return proseMentionFindings( + [ + target, + mentionSpec("spec:probe.mentioning", { + intent: { outcome: "Address the target's questions." }, + behavior: { rules: [rule] }, + }), + ], + [dependsOnEdge("spec:probe.mentioning", "spec:probe.target")], + ); + } + + function missingQuestion(token: string, key: string) { + return mentionError( + "spec:probe.mentioning", + token, + "behavior.rules[0]", + `Mention in "spec:probe.mentioning" at behavior.rules[0] points to missing entry "question.${key}" of "spec:probe.target".`, + ); + } + + it("resolves to an open question that carries the key, beside unkeyed and prose questions", () => { + const target = questionTarget({ + openQuestions: [ + "A prose question.", + { question: "Is the name final?", blocking: false }, + { + question: "Does the owner widen the aggregate?", + blocking: true, + key: "aggregateReach", + }, + ], + }); + + expect( + questionFindings(target, "Settle spec:probe.target#question.aggregateReach first."), + ).toEqual([]); + }); + + it("fails every mention of an old address once its key is renamed", () => { + const target = questionTarget({ + openQuestions: [{ question: "Does the owner widen the aggregate?", key: "aggregateScope" }], + }); + + expect( + questionFindings( + target, + "Settle spec:probe.target#question.aggregateReach, then spec:probe.target#question.aggregateScope.", + ), + ).toEqual([missingQuestion("spec:probe.target#question.aggregateReach", "aggregateReach")]); + }); + + it("finds no entry on a Spec whose questions carry no key, or that has none", () => { + const unkeyed = questionTarget({ + openQuestions: ["aggregateReach", { question: "aggregateReach", blocking: true }], + }); + const bare = questionTarget({}); + const rule = "Settle spec:probe.target#question.aggregateReach first."; + const expected = [ + missingQuestion("spec:probe.target#question.aggregateReach", "aggregateReach"), + ]; + + expect(questionFindings(unkeyed, rule)).toEqual(expected); + expect(questionFindings(bare, rule)).toEqual(expected); + }); + + it("reads question keys only, never the Design or UI keys of the Spec", () => { + const target = mentionSpec("spec:probe.target", { + intent: { + outcome: "Hold one question.", + openQuestions: [{ question: "Where does the page live?", key: "pageHome" }], + }, + design: { aggregateReach: "A Design entry." }, + ui: { aggregateReach: "A UI entry." }, + }); + + expect( + questionFindings( + target, + "Read spec:probe.target#question.aggregateReach and spec:probe.target#design.pageHome.", + ), + ).toEqual([ + // Findings sort by their related token, so the Design address reports first. + mentionError( + "spec:probe.mentioning", + "spec:probe.target#design.pageHome", + "behavior.rules[0]", + 'Mention in "spec:probe.mentioning" at behavior.rules[0] points to missing entry "design.pageHome" of "spec:probe.target".', + ), + missingQuestion("spec:probe.target#question.aggregateReach", "aggregateReach"), + ]); + }); + + it("treats description as an ordinary question key", () => { + const target = questionTarget( + { openQuestions: [{ question: "Is description a key?", key: "description" }] }, + { description: "The leading prose." }, + ); + + expect( + questionFindings(target, "Settle spec:probe.target#question.description first."), + ).toEqual([]); + expect( + questionFindings(target, "Quote spec:probe.target#design.description in full."), + ).toEqual([ + mentionError( + "spec:probe.mentioning", + "spec:probe.target#design.description", + "behavior.rules[0]", + 'Mention in "spec:probe.mentioning" at behavior.rules[0] points to missing entry "design.description" of "spec:probe.target".', + ), + ]); + }); + + it("refuses a question address with a key off the grammar by the id grammar's reason", () => { + const target = questionTarget({ + openQuestions: [{ question: "Upper?", key: "AggregateReach" }], + }); + + expect( + questionFindings(target, "Settle spec:probe.target#question.AggregateReach first."), + ).toEqual([ + mentionError( + "spec:probe.mentioning", + "spec:probe.target#question.AggregateReach", + "behavior.rules[0]", + 'Mention "spec:probe.target#question.AggregateReach" in "spec:probe.mentioning" at behavior.rules[0] is not a Spec id or entry address: entry address key must be lower-camel ASCII', + ), + ]); + }); + }); + it("checks a self-address for its entry and never warns on it", () => { const findings = proseMentionFindings([ mentionSpec( @@ -1542,13 +1882,13 @@ describe("conformance/prose-mentions — prose mentions and their entry addresse "an entry suffix with no key", "spec:probe.target#design.", "spec:probe.target#design", - "entry address must be
. with section design or ui", + "entry address must be
. with section design, ui, or question", ], [ - "an entry suffix outside design and ui", + "an entry suffix outside design, ui, and question", "spec:probe.target#model.x", "spec:probe.target#model.x", - "entry address must be
. with section design or ui", + "entry address must be
. with section design, ui, or question", ], ])( "reads the whole token through %s and refuses it, never the existing prefix",