diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89d3dae..1c3ec2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,3 +41,6 @@ jobs: - name: Build run: npm run build + + - name: Validate and view the example (the conformance + honesty gate + the derived Design Review) + run: npm run check:example diff --git a/.prettierignore b/.prettierignore index 027aaef..2b8bd2d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,12 @@ plans/** # Archived review artifacts - verbatim historical records, not format-policed reviews/ + +# The golden graph oracle carries the serializer's exact bytes (serialize.ts owns all output +# bytes); reformatting it would break the byte-compare against extractor output +test/fixtures/checkout-v1/expected-graph.json + +# The golden Design Review carries the renderer's exact bytes (design-review.ts owns them); +# same rule for the generated artifacts themselves — derived output is never format-policed +test/fixtures/checkout-v1/expected-design-review/ +generated/ diff --git a/AGENTS.md b/AGENTS.md index 99f6725..774d4b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,8 @@ Progressive disclosure — start at the top, follow the pointers down. The MVP proves the founding principle on **one** bounded context — Order Management, `pack:checkout-v1`, ~8–12 specs (`spec:orders.create-order` + a few child scenarios/rules + 1 NFR + the parent `spec:orders.order-management` -behavior + the pack); **not** the whole checkout flow. It is built as thin **end-to-end slices on the Phase 0 +behavior + the pack); **not** the whole checkout flow. The worked example lives at `examples/checkout-v1` +(documented walkthrough in its README). It is built as thin **end-to-end slices on the Phase 0 foundation**. `docs/concept/07` is the slice roadmap; **`plans/` holds the live, canonical per-session plan** — read it before writing code. @@ -67,7 +68,7 @@ read it before writing code. | **2** | Generic anchors + implementation binding + spec↔test linkage → `verifies` edges (`anchored` claim). | | **3** | Core conformance + honesty checks (referential integrity · duplicate IDs · honest readiness against the floor · orphans · `verifies` linkage · authoring-shape honesty) + the CI gate. | | **4** | The agent surface (the `reader` — entry adapters + impact) + the Design Review / one generated read-only view — both fully derived. | -| **5** | Polish: the `sdp` CLI (`build` · `validate` · maybe `explain`/`search`), error messages, the documented example, a clean-repo determinism test. | +| **5** | Polish: the CLI surface resolved (`build` · `validate` · `view`; `explain`/`search` below the second-caller bar), one diagnostic rendering rule, the documented example walkthrough, the clean-repo determinism test. | > **Tracer-bullet discipline.** Author the example specs and anchored code *first*, so the DSL and extractor are > forced to be usable before they are finished. If the example doesn't typecheck, fix the DSL — not the example. diff --git a/docs/concept/00-vision-scope-and-mvp-boundary.md b/docs/concept/00-vision-scope-and-mvp-boundary.md index 86ba89c..04dbc50 100644 --- a/docs/concept/00-vision-scope-and-mvp-boundary.md +++ b/docs/concept/00-vision-scope-and-mvp-boundary.md @@ -51,7 +51,7 @@ The MVP proves the founding principle — *one regenerable graph derived from th - **Typed `Spec` DSL** in TypeScript: `spec()`, `pack()`, sections, relations, the three descriptors (`kind` · `altitude` · `readiness`), enrichment-in-place, refinement into child specs. One primitive, stable IDs, no artifact migration. - **Generic source anchors** that bind any code location (class, function, route, module) to a spec ID + optional component. Framework-neutral — *how* the runtime is wired is an extractor detail, not a job. -- **The `ts-morph` one-graph extractor**: one canonical, regenerable graph from `/specs/**/*.sdp.ts` + anchors + basic test discovery, with honest edge `claim`s. +- **The `ts-morph` one-graph extractor**: one canonical, regenerable graph from every `*.sdp.ts` under the extraction root (conventionally `/specs/`) + anchors + basic test discovery, with honest edge `claim`s. - **Core conformance + honesty checks**: referential integrity (no dangling ID references), duplicate-ID detection, honest readiness (against the readiness floor), orphan detection, `verifies` linkage from tests to specs, and authoring-shape honesty (no hand-authored derived edges or delivery facts). CI fails on errors. - **One generated read-only view**: a derived, regenerable human-readable projection (spec tree + per-spec detail with readiness, relations, impact list, source links). - **Bidirectional spec↔test trace**: query "what verifies this spec?" and "what does this test cover?" from the graph. @@ -79,7 +79,7 @@ Everything below is real, designed-for, and out of the first slice. Each is cut | **MCP surface** (designed-in, deferred build) | The integration surface for *user-facing apps* — one more projection of the one read model, **distinct from the agent surface** (agents *script*; apps *integrate*). Designed-in, but its build is deferred and its shape is a fresh design — not an afterthought, and not carried over from any prior implementation. | | **Architecture enforcement** (forbidden-dependency tiers, ts-arch tests, custom rules) | A whole validation competency. MVP keeps only core graph invariants. | | **Incremental builds / caching / sharding** | Full rebuild is fine at MVP scale. | -| **Full CLI** (evidence, migrate, ai subcommands) | MVP CLI is `sdp build` and `sdp validate` (plus maybe a simple `explain`/`search`). | +| **Full CLI** (evidence, migrate, ai subcommands) | MVP CLI is `sdp build` · `sdp validate` · `sdp view`. `explain`/`search` stay below the second-caller bar (`06` §3): the agent scripts the reader, the human reads the Design Review — a terminal verb over the same joins would render the same information a third time. Revisit on measured pain (`07` §5). | Three stances shape the deferrals above: the graph is exposed to agents from day one as the **agent surface** (a typed graph the agent scripts, not a verb wall); there is no patch loop (edits route intent → agent → git); and verification is structural — test run verdicts are CI's, never ingested. diff --git a/docs/concept/01-founding-principles-and-invariants.md b/docs/concept/01-founding-principles-and-invariants.md index a33deb8..14d1716 100644 --- a/docs/concept/01-founding-principles-and-invariants.md +++ b/docs/concept/01-founding-principles-and-invariants.md @@ -27,7 +27,7 @@ If a successor kept only the essentials, the design stands or falls on these. Ea ### P4 — One enrichable primitive **Principle · CORE.** A single object shape — the `Spec` — carries every durable delivery artifact; the familiar delivery nouns (Use Case, NFR, Decision Record; Epic, Feature, Story) are **named coordinates** on it, not separate artifact types. Realization is a **derived delivery fact** (`implemented` / `has-verifier` / `observed`), never an authored artifact. Refinement is *enrichment of the same identified object*, never migration into a different artifact type (which loses information). This is the central reframing of the whole design. *(The concrete field set is Representation; the "one primitive, enrich not convert" thesis is the Principle.)* See [`02-core-model.md`](./02-core-model.md). -**Corollary — significance governs detail, not a tier.** Because refinement is enrichment of one object — not promotion through fixed readiness stages — a spec carries detail only where the work is *architecturally significant or genuinely novel*. The Nth instance of an established shape (a CRUD endpoint, a standard codec, a barrel) earns a **reference, not a re-derivation**; padding a spec to "fill a level" destroys signal and is pure cleanup debt. Readiness (P8) is **stated by the author and checked against a floor — never a quota of artifacts to produce.** Fixed tiers pressure an author to fill the tier rather than the need — AI authors especially, which replicate the prevailing detail level even where it does not fit. *Enrich what matters; reference what is standard; pad nothing.* +**Corollary — significance governs detail, not a tier.** Because refinement is enrichment of one object — not advancement through fixed readiness stages — a spec carries detail only where the work is *architecturally significant or genuinely novel*. The Nth instance of an established shape (a CRUD endpoint, a standard codec, a barrel) earns a **reference, not a re-derivation**; padding a spec to "fill a level" destroys signal and is pure cleanup debt. Readiness (P8) is **stated by the author and checked against a floor — never a quota of artifacts to produce.** Fixed tiers pressure an author to fill the tier rather than the need — AI authors especially, which replicate the prevailing detail level even where it does not fit. *Enrich what matters; reference what is standard; pad nothing.* ### P5 — Statically-extractable authoring **Principle · CORE.** Spec and anchor source is restricted to static, side-effect-free literals — no loops, conditionals, computed IDs, interpolated IDs, IO, async, or imports of product code — so a static analyzer reifies it deterministically. Treat a spec file as "a JSON file that TypeScript happens to validate." This is the precondition for P3 on the authoring side. *(The specific allowed grammar and the choice of `ts-morph` are Representation; the requirement to be statically extractable is the Principle.)* @@ -96,7 +96,7 @@ These named invariants sharpen the design. **Principle · CORE.** One bad input never poisons the whole build. A non-static expression drops that one property and keeps the rest of the spec; an unresolved reference still serializes (with a sentinel) and surfaces as a validation error, rather than aborting the extractor. The pipeline degrades *locally*, not globally. ### L8 — Generated output is disposable -**Principle · CORE.** Everything under `generated/` is gitignored and regenerable; any consumer may delete it and rebuild. A single carve-out — a generated `spec-ids` union type consumed by `tsc` for early referential-integrity checks — is itself a known fragility (it must regenerate before `tsc`, or `tsc` lies), and is *optional*: the graph validator catches the same broken references at build time regardless. Treat the union as a convenience, not a load-bearing gate. +**Principle · CORE.** Everything under `generated/` is gitignored and regenerable; any consumer may delete it and rebuild. A single designed-for carve-out — a generated `spec-ids` union type consumed by `tsc` for early referential-integrity checks — would itself be a known fragility (it must regenerate before `tsc`, or `tsc` lies), and stays *optional and deferred* (the MVP ships without it): the graph validator catches the same broken references at build time regardless. Treat the union as a convenience, never a load-bearing gate. ### L9 — The `Spec` envelope is a stability contract; sections are the extension surface **Principle · CORE.** The outer `Spec` shape is intentionally minimal and changes almost never. New capability arrives by **adding sections or enum members**, not by changing the top-level envelope. The envelope is the stability contract; sections are where the system grows. @@ -114,7 +114,7 @@ These appear in the design and matter, but none is a Principle. Swapping any of | Specific anchor syntaxes (decorator / JSDoc / anchor const) | P9/P10 (declared vs anchored) | CORE | | Flat node/edge arrays (hierarchy as edges, not nesting) | P2 (one queryable read model) | CORE | | The enum member sets on each descriptor | P8 (three descriptors) | CORE — reconciled in `02` | -| Generated `spec-ids` union for compile-time ref checks | early referential integrity | CORE (optional, L8) | +| Generated `spec-ids` union for compile-time ref checks | early referential integrity | ASPIRATIONAL (optional convenience, L8) | | The runtime-composition adapter (Effect / Awilix / manual wiring) | "read architecture from live wiring" | ASPIRATIONAL | | HTML / Web Components for the rich view | "rich, shareable read surface from the graph" | ASPIRATIONAL | | Impact graph (derived import/symbol graph) | P10 corollary (impact + curation-assist) | ASPIRATIONAL (thin version may be MVP — `07`) | diff --git a/docs/concept/02-core-model.md b/docs/concept/02-core-model.md index 5cf69ed..0e200cb 100644 --- a/docs/concept/02-core-model.md +++ b/docs/concept/02-core-model.md @@ -136,7 +136,7 @@ Sections carry the detail. They are the **extension surface**: the system grows | `verification` | mode (manual / reviewed / contract / executable) + criteria | A verifying test *existing and enabled* is the derived `has-verifier` delivery fact (§2), not an authored field here. Pass/fail is **not** in the graph — it is CI's, operational. | | `ui` | references to component stories, design-tool nodes, visual baselines, accessibility status | **Aspirational.** Always links, never owns or renders. | -The `decision` section carries **no `status` field** (rejected by the typing law, MD-11): a decision's adoption arc is the envelope's `readiness` (raised → explored → written → ratified), checked against the floor like any spec; replacement is the `supersedes` relation; a *rejected* path is not a truth-spec at all — it lives in the chosen decision's `alternatives` / `consequences`. One concept, one place. +The `decision` section carries **no `status` field** (rejected by the typing law, MD-11): a decision's adoption arc is the envelope's `readiness` (`idea` raised → `scoped` explored → `defined` written → `ready` ratified), checked against the floor like any spec; replacement is the `supersedes` relation; a *rejected* path is not a truth-spec at all — it lives in the chosen decision's `alternatives` / `consequences`. One concept, one place. ### The typing law — which sections have typed shapes (MD-11) @@ -161,7 +161,7 @@ Two laws make the duality safe (content-only sections — DECISIONS MD-10): ```ts // idea export const CreateOrder = spec({ - id: "spec:orders.create-order", + id: specId("spec:orders.create-order"), title: "Customer creates an order", kind: "behavior", altitude: "feature", @@ -171,7 +171,7 @@ export const CreateOrder = spec({ outcome: "turn a valid cart into an order", value: "customers can complete purchases", }, - relations: [refines("spec:orders.order-management")], + relations: [refines(specId("spec:orders.order-management"))], }); ``` @@ -180,7 +180,7 @@ Later, the *same* spec — same ID — gains sections and climbs readiness: ```ts // ready — same object, enriched (no artifact migration) export const CreateOrder = spec({ - id: "spec:orders.create-order", + id: specId("spec:orders.create-order"), title: "Customer creates an order", kind: "behavior", altitude: "feature", @@ -192,8 +192,8 @@ export const CreateOrder = spec({ { flavor: "performance", statement: "order creation is fast enough for checkout", target: "p95 < 300ms" }, ], relations: [ - refines("spec:orders.order-management"), - decidedBy("spec:decisions.order-lifecycle"), + refines(specId("spec:orders.order-management")), + decidedBy(specId("spec:decisions.order-lifecycle")), ], }); ``` @@ -204,7 +204,7 @@ A low-altitude example becomes executable the same way — it is not a separate ```ts export const ValidCartCreatesOrder = spec({ - id: "spec:orders.create-order.valid-cart", + id: specId("spec:orders.create-order.valid-cart"), title: "Valid cart creates an order", kind: "example", altitude: "story", @@ -217,7 +217,7 @@ export const ValidCartCreatesOrder = spec({ }], }, verification: { mode: "executable", criteria: ["an order row exists", "an OrderCreated event is published"] }, - relations: [refines("spec:orders.create-order"), verifies("spec:orders.create-order")], + relations: [refines(specId("spec:orders.create-order")), verifies(specId("spec:orders.create-order"))], }); ``` @@ -238,10 +238,10 @@ A `Pack` clusters related specs (a feature initiative, a bounded slice) so a tea ```ts export const CheckoutV1 = pack({ - id: "pack:checkout-v1", + id: packId("pack:checkout-v1"), title: "Checkout v1", framing: "let customers complete purchases reliably — lifts conversion, cuts failed orders", - modelRefs: ["spec:checkout.glossary"], // → standalone kind:"model" specs; shared vocabulary is never inlined twice + modelRefs: [ref("spec:checkout.glossary")], // → standalone kind:"model" specs; shared vocabulary is never inlined twice specs: [ ref("spec:orders.create-order"), ref("spec:payments.authorize-payment"), @@ -277,7 +277,7 @@ Namespaces in the MVP: `spec`, `pack`, `impl`, `api`, `test`, `component`, `doc` `doc:` is reserved for a genuinely *external* document linked from a decision spec (e.g. `doc:adr-order-lifecycle`) — never for an in-system decision, which is a `spec:decisions.*` spec. -Code and specs link by these IDs *in strings*, never by import edges (P6) — which is the only thing that lets either side survive heavy refactoring. The cost of string IDs is typos; that is what referential-integrity checks exist to catch (`05`). The optional generated `spec-ids` union type pushes some of those checks to `tsc`, but it is a convenience, not a load-bearing gate (L8). +Code and specs link by these IDs *in strings*, never by import edges (P6) — which is the only thing that lets either side survive heavy refactoring. The cost of string IDs is typos; that is what referential-integrity checks exist to catch (`05`). An optional generated `spec-ids` union type could push some of those checks to `tsc` — a deferred convenience, never a load-bearing gate (L8). > **IDs carry no history.** IDs are stable by convention and survive refactors; renaming one is a repo edit that git records. The graph does not bookkeep ID history (see `01`, git is the event log). @@ -302,7 +302,7 @@ MVP relation vocabulary (a Representation; extensible): Two notes carried from the language ratification: the verb forms align with UML where a standard stereotype exists (`refines` ≈ «refine», `dependsOn` ≈ UML *Dependency*, `decidedBy` ≈ «trace», `verifies` ≈ «verify») — adopted nouns, per the governing rubric. And `constrainedBy` / `decidedBy` are deliberately kept **distinct** from a generic `dependsOn`: "bounded by an NFR" and "shaped by a decision" are high-value, separately-queryable intents that a generic dependency edge would flatten. -> **`doc:` targets are a named deferral (MD-16).** The DSL's relation builders and `ref()` accept only `spec:` targets today, so `decidedBy` → an external `doc:` ADR is designed-for but not yet representable; until the need arrives, an external ADR is referenced from the decision spec's body, not by a typed edge. (The glossary's flagged-ambiguities entry and the notes in `src/ids.ts` / `src/model/relations.ts` carry the same flag.) +> **`doc:` targets are a named deferral (MD-16).** The DSL's relation builders and `ref()` accept only `spec:` targets, so `decidedBy` → an external `doc:` ADR is designed-for but not yet representable; until the need arrives, an external ADR is referenced from the decision spec's body, not by a typed edge. (The glossary's flagged-ambiguities entry and the notes in `src/ids.ts` / `src/model/relations.ts` carry the same flag.) **Derived edges — never authored:** diff --git a/docs/concept/03-the-one-graph.md b/docs/concept/03-the-one-graph.md index bcfb3da..0650ed2 100644 --- a/docs/concept/03-the-one-graph.md +++ b/docs/concept/03-the-one-graph.md @@ -22,17 +22,17 @@ Two pure steps: `graph = f(repo)` and `output = f(graph)`. The extractor is the ### What the extractor reads -- **Typed spec files** (`/specs/**/*.sdp.ts`) — the declared layer: specs, packs, relations. -- **Source-code anchors** — the anchored layer: an **anchor** binds a code location to a spec ID and minimal structural facts (component, satisfies, implements). Anchors carry *no* intent (see `04`). -- **Structural facts** — the inferred layer: `ts-morph`-derived structure (which file defines which `impl`, basic test discovery linking `test:*` to the specs they `verify`). In the MVP this layer is kept minimal and advisory. +- **Typed spec files** — every `*.sdp.ts` under the extraction root (conventionally `/specs/`; discovery is by suffix alone, outside tooling-output and dot-directories — the `.sdp.ts` extension, MD-15) — the declared layer: specs, packs, relations. +- **Source-code anchors** — the anchored layer: an **anchor** binds a code location to a spec ID — identity, an optional label, and one `satisfies`/`verifies` target; richer structural facts are aspirational (`04` §2). Anchors carry *no* intent (see `04`). +- **Structural facts** — the inferred layer: machine-derived structure (imports, calls, symbol identity). Designed-in — the `claim` value and the advisory edge row exist, and every consumer decodes them — but **empty in the MVP**: the entry adapters and file-level impact resolve off the curated layers (`06` §2), so nothing yet needs an inferred edge; the first producer is the aspirational impact graph. The graph is **flat**: arrays of nodes and arrays of edges. Hierarchy and containment are expressed as edges (`belongsTo`, `refines`), never by nesting nodes inside nodes. This is a Representation (chosen for uniform querying and cheap diffs), held as a hard rule. ```jsonc { - "schemaVersion": "0.1.0", + "schemaVersion": "0.3.0", "nodes": [ - { "id": "spec:orders.create-order", "nodeType": "Primitive", "specKind": "behavior", "altitude": "feature", "readiness": "ready", "deliveryFacts": ["implemented"], "claim": "declared", "file": "specs/orders/create-order.sdp.ts" }, + { "id": "spec:orders.create-order", "nodeType": "Primitive", "claim": "declared", "specKind": "behavior", "altitude": "feature", "readiness": "ready", "title": "Customer creates an order", "file": "specs/orders/create-order.sdp.ts", "deliveryFacts": ["implemented"] }, { "id": "impl:orders.create-order-use-case", "nodeType": "CodeNode", "claim": "anchored", "file": "src/orders/create-order.use-case.ts", "line": 12 } ], "edges": [ @@ -53,7 +53,7 @@ Every edge in the graph has a fixed contract: where it comes from, what `claim` | `refines` | Primitive → Primitive | authored | declared | spec `relations[]` | **error** if target missing | `ready` floor: target ≥ `defined` | — | | `dependsOn` | Primitive → Primitive | authored | declared | spec `relations[]` | **error** if target missing | `ready` floor: target ≥ `defined` | — | | `constrainedBy` | Primitive → Primitive (`constraint`) | authored | declared | spec `relations[]` | **error** if target missing | — | — | -| `decidedBy` | Primitive → Primitive (`decision`) / `doc:` | authored | declared | spec `relations[]` | **error** if target missing (unless `doc:`) | — | — | +| `decidedBy` | Primitive → Primitive (`decision`) | authored | declared | spec `relations[]` | **error** if target missing (a `doc:` target is a named deferral — carried evidence, MD-16; `02` §6) | — | — | | `verifies` | Primitive (`example`) **or** Anchor (test) → Primitive | authored | `declared` (from an example) / `anchored` (from a test anchor) | spec `relations[]` **or** `specTest` anchor | **error** if target missing | — | contributes `has-verifier` to the **target** (if the verifier is *enabled*) | | `supersedes` | Primitive (`decision`) → Primitive (`decision`) | authored | declared | spec `relations[]` | **error** if target missing | — | — | | `satisfies` | CodeNode → Primitive | derived (from an anchor) | anchored | source **anchor** | **error** if target missing | `ready` floor: present anchors must *resolve* | contributes `implemented` to the **target** | diff --git a/docs/concept/04-authoring-and-binding.md b/docs/concept/04-authoring-and-binding.md index 7eb8952..573d0bf 100644 --- a/docs/concept/04-authoring-and-binding.md +++ b/docs/concept/04-authoring-and-binding.md @@ -8,13 +8,13 @@ Realises **P5** (statically extractable), **P6** (ID-linked), **P9/P10** (anchor ## 1. The TypeScript Spec DSL — canonical (CORE) -Specs are authored as typed TypeScript in `/specs/**/*.sdp.ts` — the Protocol's own compound extension (MD-15; the `.stories.tsx` pattern), deliberately **not** `.spec.ts`, which every JS test runner's default glob would try to execute. The DSL is a thin set of helpers (`spec`, `pack`, relation builders) over the `Spec` shape from `02`. +Specs are authored as typed TypeScript in `*.sdp.ts` files, discovered by suffix anywhere under the extraction root (conventionally `/specs/`) — the Protocol's own compound extension (MD-15; the `.stories.tsx` pattern), deliberately **not** `.spec.ts`, which every JS test runner's default glob would try to execute. The DSL is a thin set of helpers (`spec`, `pack`, the branded-ID builders, relation builders) over the `Spec` shape from `02`. ```ts -import { spec, refines, dependsOn } from "@libar-dev/software-delivery-protocol"; +import { dependsOn, refines, spec, specId } from "@libar-dev/software-delivery-protocol"; export const CreateOrder = spec({ - id: "spec:orders.create-order", + id: specId("spec:orders.create-order"), title: "Customer creates an order", kind: "behavior", altitude: "feature", @@ -30,7 +30,7 @@ export const CreateOrder = spec({ rules: ["only valid carts can become orders", "creating an order emits OrderCreated"], examples: ["an expired payment card is declined before any order is created"], }, - relations: [refines("spec:orders.order-management"), dependsOn("spec:payments.authorize-payment")], + relations: [refines(specId("spec:orders.order-management")), dependsOn(specId("spec:payments.authorize-payment"))], }); ``` @@ -47,7 +47,7 @@ If a non-static expression appears, the extractor responds in **two tiers**, dra - **Envelope fields are hard errors.** A non-static `id`, `kind`, `altitude`, `readiness`, or any **relation target** **fails the build** — these are the keys the graph is built on, so the extractor must never guess, drop, or anonymise them. A spec whose identity or position cannot be reified deterministically is not extracted at all. - **Optional section detail degrades gracefully.** A non-static expression *inside an optional section* drops *that one property* with a warning, keeping the rest of the spec (graceful partial extraction, L3). It never aborts the build for section detail. -A lint rule (`sdp/spec-static`) can flag both tiers earlier, but the extractor is the backstop. +A designed-for lint rule (`sdp/spec-static`) would flag both tiers earlier; the extractor is the backstop. ### Enrichment in place, refinement into children @@ -75,18 +75,24 @@ export class CreateOrderUseCase { /* ... */ } /** @arch.node id=impl:orders.create-order-use-case satisfies=spec:orders.create-order */ export function createOrder() { /* ... */ } -// Anchor-constant form (equivalent, decorator-free) -export const _anchor = anchorImplementation({ id: "impl:orders.create-order-use-case", satisfies: ["spec:orders.create-order"] }); +// Anchor-constant form (equivalent, decorator-free) — the one surface the MVP extractor reads +export const _anchor = codeAnchor({ + id: codeAnchorId("impl:orders.create-order-use-case"), + satisfies: ref("spec:orders.create-order"), +}); ``` -The three syntaxes are interchangeable Representations; the *binding* is the thing. A team picks one style. -The builder generalizes to **`codeAnchor`** over the implementation-flavored code namespaces (`impl` / `api` / -`component`) — the generic `codeAnchor` decision (MD-8), landing with Slice-2 anchor extraction; until then the -DSL ships the narrower `anchorImplementation`. +The three syntaxes are interchangeable Representations; the *binding* is the thing. A team picks one style; +the MVP extracts the **anchor-constant form** (a top-level `const` initialized with the builder call) — the +decorator and JSDoc forms remain unextracted Representations. The builder is the generic **`codeAnchor`** +over the implementation-flavored code namespaces (`impl` / `api` / `component`) — the generic `codeAnchor` +decision (MD-8, folded into the builder's doc-comment in `src/model/anchors.ts`). One binding target per +anchor (two bindings are two anchors); the decorator sketch above shows an array form and a `component` +field that are possible later Representations, not the landed signature. ### Anchors assert a binding — never intent (P9/P10) -An anchor says exactly one thing: *"this code location is the implementation/test **binding** for this Spec ID"* — a binding assertion only, never system-truth content (DECISIONS R1). It binds a code location to a graph ID and its structural bindings (`component`, `satisfies`, `implements`, and — aspirationally — `handles`/`emits`). It is **forbidden** from carrying anything spec-level: behavior, rationale, readiness, acceptance criteria, or delivery facts. This asymmetry is load-bearing: +An anchor says exactly one thing: *"this code location is the implementation/test **binding** for this Spec ID"* — a binding assertion only, never system-truth content (DECISIONS R1). The landed contract is exactly that minimal: `id` · an optional display `label` · **one** binding target (`satisfies` on a code anchor, `verifies` on a test anchor). Any other field is an extraction **error** — the anchored-surface twin of authoring-shape honesty. Richer structural bindings (`component`, `implements`, `handles`/`emits`) are **ASPIRATIONAL** — possible later extensions (see the inline-vs-centralized open question, `07` §4), never the MVP contract. An anchor is **forbidden** from carrying anything spec-level: behavior, rationale, readiness, acceptance criteria, or delivery facts. This asymmetry is load-bearing: - **Intent stays centralized** in the spec files, never scattered through code comments. - Anchors produce **anchored**-`claim` edges, distinct from **declared** relations (P9). @@ -158,7 +164,7 @@ Modules that declare controls + an `expected()` model + `coverage()`, rendered a orders/create-order.valid-cart.test.ts // specTest verifies spec:... /generated // gitignored, disposable (L8) graph.json - view/ // the one generated read-only view + design-review/ // the one generated read-only view ``` Specs are not separate from code — they are part of the codebase, committed alongside it. That is the whole point: the repo is the single source of truth (P1), and authoring is editing TypeScript + git (the MVP write path). diff --git a/docs/concept/05-validation-and-honesty.md b/docs/concept/05-validation-and-honesty.md index f87bd2a..57d9abd 100644 --- a/docs/concept/05-validation-and-honesty.md +++ b/docs/concept/05-validation-and-honesty.md @@ -41,24 +41,25 @@ Types describe **shape**; validators decide **completeness** (P7). Completeness These are the non-negotiable core. CI fails on any error. They split across the two families. -**They run over the one graph — there is exactly one validation path** (MD-14): source → extract (static reification, P5) → graph → checks; `sdp validate` is `sdp build` + checks. Validating any *evaluated* form (importing spec modules and checking the resulting objects) would check a phantom — a non-static expression evaluates to a value on import but is dropped by static reification, so the checks could pass a spec the graph doesn't actually hold. The pre-graph `AuthoredModel` is a stand-in that retires into (at most) an extractor-internal shape when the extractor lands (Slice 1); it is never a second public validation seam. Authoring-time feedback is the type system's job (typed sections, `02` §3) plus the `sdp/spec-static` lint — not a parallel validator path. +**They run over the one graph — there is exactly one validation path** (MD-14): source → extract (static reification, P5) → graph → checks; `sdp validate` is `sdp build` + checks, and `validateGraph` is the sole validation seam. Validating any *evaluated* form (importing spec modules and checking the resulting objects) would check a phantom — a non-static expression evaluates to a value on import but is dropped by static reification, so the checks could pass a spec the graph doesn't actually hold. For the same reason there is no pre-graph validation seam of any kind: a check that reads anything but the derived graph is a second validation path, forbidden. Authoring-time feedback is the type system's job (typed sections, `02` §3) — static reification (P5) is what rejects non-static authoring, and the `sdp/spec-static` lint is a designed-for earlier surfacing of the same tiers (`04` §1), never a parallel validator path. **Conformance checks:** 1. **Referential integrity.** Every ID referenced (in relations, `modelRefs`, anchors) resolves to a node that exists. A dangling reference is an error — with a "did you mean…?" suggestion where possible. *(This is the cost of string-ID linkage from P6, paid down at build time.)* 2. **Duplicate-ID detection.** No two nodes share an ID. A duplicate is an error, never an auto-resolved merge (L2 — ambiguity is loud). -3. **`claim` separation never collapsed.** A `declared` edge is never silently "satisfied" by an `inferred` one; node/edge typing (`nodeType` / `specKind` / `claim`) stays valid and distinct (`03`, `04`). +3. **`claim` separation never collapsed.** A `declared` edge is never silently "satisfied" by an `inferred` one; node/edge typing stays valid and distinct (`03`, `04`) — `nodeType` / `claim` / the edge-contract rows, and the three descriptors (`specKind` · `altitude` · `readiness`) carry their ratified values. The floor is never evaluated over an unratified descriptor (fail closed, never a crash or a silent skip). The edge-contract rows include the kind-typed endpoints (`03` §1): `constrainedBy` → a `rule`/`constraint`-kind spec (`02` §6's "a rule / NFR / policy spec") · `decidedBy` → a `decision`-kind spec · `supersedes` only between `decision`-kind specs; the declared-`verifies`-from-an-example row stays check 4's informative warning (a wrong-kind verifier confers nothing rather than failing the build). 4. **`verifies` linkage.** The bidirectional spec↔test trace resolves: a test anchored `verifies: spec:X` must point at an existing spec. **Honesty checks:** 5. **Authoring-shape honesty.** No spec or pack file hand-authors a derived edge or fact — `satisfies`, an `anchored` edge, an `inferred` edge, or any delivery fact (`implemented` / `has-verifier` / `observed`). Those are the machine's to derive; authoring one is a violation. -6. **Honest readiness (the readiness floor).** A spec that *states* a readiness rung but lacks the structure that rung requires fails. **Readiness is stated by the author; validators verify the stated rung against the floor** (P8). See §3. +6. **Derived-facts honesty.** A `Primitive` node's stated delivery facts equal what the one derivation rule recomputes from the graph's resolving binding edges. Extractor output holds by construction; the check has teeth for any other graph producer — a stated fact no binding earns (including `observed`, which nothing derives yet) is authored derived truth, and an omitted fact corrupts the backlog/drift queries. The gap check (9) reads the recomputed facts, so a faked fact never silences it. +7. **Honest readiness (the readiness floor).** A spec that *states* a readiness rung but lacks the structure that rung requires fails. **Readiness is stated by the author; validators verify the stated rung against the floor** (P8). See §3. **Informative (a `gap` or `orphan`, not an error by default):** -7. **Orphan detection.** A spec with no relations and nothing pointing at it is surfaced (warning or error per config) — it has fallen out of the graph's connective tissue. -8. **Readiness/delivery gaps.** A `ready` spec with no resolving verifier is a surfaced `gap` (the build backlog and drift-alarm queries, `02` §2), not an error. +8. **Orphan detection.** A spec with no relations and nothing pointing at it is surfaced as a warning — it has fallen out of the graph's connective tissue. (A per-team severity override is designed-for, deferred.) +9. **Readiness/delivery gaps.** A `ready` spec with no resolving verifier is a surfaced `gap` (the build backlog and drift-alarm queries, `02` §2), not an error. Two cross-cutting honesty rules apply to all validators: @@ -69,7 +70,7 @@ Two cross-cutting honesty rules apply to all validators: ## 3. Readiness floors (CORE) -A **readiness floor** is the **minimum structural requirement to *state* a readiness rung** — a floor to clear, **never a quota to fill or a score** (significance governs detail — no tier-filling; `01`, P4 corollary). The floors are the mechanism (Principle); the specific thresholds are config a team can override. +A **readiness floor** is the **minimum structural requirement to *state* a readiness rung** — a floor to clear, **never a quota to fill or a score** (significance governs detail — no tier-filling; `01`, P4 corollary). The floors are the mechanism (Principle); the specific thresholds are a Representation — a team-overridable floor config is designed-for, deferred. The floor has two parts: **kind-blind structural clauses** (the same for every kind) and **one evidence clause that is kind-conditional** (MD-12) — `kind` is a true subtype, and the floor is where that changes what is required, in both directions (it can *relax* as well as add). @@ -104,7 +105,7 @@ Three laws bound the table: **`ready` is the structural floor plus a human's `declared` statement — not a record that a review occurred.** The Design Review (`06` §5) is *where* a human typically decides, but the graph stores **no** review/approval fact, and the validator never checks one — that would be the workflow-gating the honesty guardrail forbids (§1). Where approval provenance matters — a baseline — it is **git-native** (authorship + a signed tag, `03`), not an authored primitive (approval / RBAC stays outside the model, `07`). -> **Stated vs derived readiness.** The author *states* a `readiness`; validators can also compute a *derived* readiness from what the spec actually contains. When they diverge (stated `defined`, derived `scoped`), that divergence is itself surfaced. The MVP can ship the floor-check and add the explicit derived-readiness banner as a small follow-up; the principle — *the stated rung is not trusted, it is verified* — holds either way. (Note the verb: readiness is **stated/asserted**, never "claimed" — "claim" is reserved for the `claim` taxonomy in `04`.) +> **Stated vs derived readiness.** The author *states* a `readiness`; the same floor table also yields a *derived* readiness — the highest rung whose cumulative clauses pass. Both ship in the MVP: the floor check fails a stated rung the structure does not earn, and the Design Review renders stated beside the floor reached, naming the first unmet clause (the evaluator reports which clause fails). The divergence banner fires only in the dishonest direction — derived *at-or-above* stated is ordinary information, because the floor is a floor, never a quota that nags upward. The principle: *the stated rung is not trusted, it is verified.* (Note the verb: readiness is **stated/asserted**, never "claimed" — "claim" is reserved for the `claim` taxonomy in `04`.) --- diff --git a/docs/concept/06-consumers-and-projections.md b/docs/concept/06-consumers-and-projections.md index 0bfb723..8f85e86 100644 --- a/docs/concept/06-consumers-and-projections.md +++ b/docs/concept/06-consumers-and-projections.md @@ -70,7 +70,7 @@ This is the `claim` taxonomy (P9) elevated to two consumable surfaces: the curat **Principle · CORE.** Structured graph context beats raw text for AI; the Protocol is a *producer* of structured context, not just another consumer. This is a genuine differentiator and it is a principle. -The experiment settled *how* to expose it. The **agent surface** is a **visible, self-describing typed graph the agent *scripts*** via the CLI — deliberately **neither** of two failure modes: +The agent-surface decision (D5) settled *how* to expose it. The **agent surface** is a **visible, self-describing typed graph the agent *scripts*** via the CLI — deliberately **neither** of two failure modes: - **not a 30-verb API** — that hides the shapes and rebuilds the pipeline we are deleting; - **not raw-JSON-you-rejoin** — that makes every agent re-derive the same joins and decode the same taxonomy quirks. @@ -87,9 +87,9 @@ The **`reader`** is the *component* behind the surface: joins and `claim`/taxono - **Blast-radius** over a changeset (impact + at-risk specs). The **file-level** form (`git diff` → `byFile` → curated-graph walk) is **MVP**; **symbol-level** exhaustive reach rides the aspirational impact graph. The MVP form **reports uncovered (unanchored) changed files explicitly** (a `coverage-unknown` signal) — honest coverage, never a silently-small answer (§2). - **Irreducible joins** — e.g. the multi-hop `spec → satisfies → … → invariants/scenarios` bridge with maturity/`claim` decode. Freeze because it is a true cross-source join, not a thin walk. -Everything else (single-field traversals, group-bys, the maturity ladder) stays a script. The discriminator is not "is it a traversal" but **"would an agent hand-rolling this get it wrong?"** Freeze a typed contract only when a **second machine consumer** appears. +Everything else (single-field traversals, group-bys, the maturity ladder) stays a script. The discriminator is not "is it a traversal" but **"would an agent hand-rolling this get it wrong?"** Freeze a typed contract only when a **second machine consumer** appears — **the second-caller bar** (the name `00` §4 and `07` cite; §4 below applies it to writes). -> Context efficiency is a measured win, not a hope: keeping the data in-process and returning only conclusions ran a multi-probe session at a measured fraction of the tokens of a grep/verb-API equivalent. Freezing answers is expensive both as bytes on disk and as tokens in context. +> Context efficiency is a measured win, not a hope (the measured evidence, `DECISIONS.md`): keeping the data in-process and returning only conclusions runs a multi-probe agent session at a measured fraction of the tokens of a grep/verb-API equivalent. Freezing answers is expensive both as bytes on disk and as tokens in context. **Aspirational (named, deferred):** `bySymbol` and symbol-level / cross-package reach (they ride the exhaustive impact graph — §2 boundary); token-budgeted self-contained slices (`per-pack`, `change-impact-`); the **MCP surface** (§7) exposing a read-only window; GraphRAG retrieval for very large graphs. All stay inside the read-only gate (§4). @@ -131,7 +131,7 @@ Why patching dissolves: **Principle · CORE (concept).** The flagship curated surface is the **Design Review**: a `Spec` (or a `Pack`) rendered **in context** — its neighbors, relations, `claim`/delivery badges, auto-generated **design questions** (from blocking open questions + `gap`s), and a **findings** table. It adopts the recognized SDLC noun. -- It is the context in which a human **decides** to state `ready`: a spec is reviewed *in context* (alone and in its related set / `Pack`), and stating `ready` is the human's call coming out of that review. The review is **never an automated gate** — validators check only the structural **readiness floor** (`05`); they do not adjudicate the review or promote a spec. This keeps the honesty guardrail from `00`/`05` (checks police conformance & honesty, never workflow) intact (`02` §2, `05`). So `ready` is an **authored `declared` statement** — its *checkable* content is the floor; that a review actually happened is **not a fact the graph records**, so where review provenance matters it rides **git** (authorship, commit, the baseline tag — `03` §5), never an authored approval primitive. +- It is the context in which a human **decides** to state `ready`: a spec is reviewed *in context* (alone and in its related set / `Pack`), and stating `ready` is the human's call coming out of that review. The review is **never an automated gate** — validators check only the structural **readiness floor** (`05`); they do not adjudicate the review or state `ready` on the author's behalf. This keeps the honesty guardrail from `00`/`05` (checks police conformance & honesty, never workflow) intact (`02` §2, `05`). So `ready` is an **authored `declared` statement** — its *checkable* content is the floor; that a review actually happened is **not a fact the graph records**, so where review provenance matters it rides **git** (authorship, commit, the baseline tag — `03` §5), never an authored approval primitive. - It is a **pure projection** — findings resolve through the edit loop (§4); there is **no stored `Finding` type**, no second store. - *Concept is core; rich diagrams grow later* — the MVP renders the relationship slice; heatmaps and interactive trees are aspirational (Spec Studio, §8). @@ -139,7 +139,7 @@ Why patching dissolves: The MVP human view *is* the Design Review's relationship slice: a single 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). -**Form is a Representation.** Clean generated HTML (tree + per-spec pages) or high-quality generated Markdown — the MVP needs *one* read-only derived view. 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). +**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/concept/07-mvp-roadmap-and-open-questions.md b/docs/concept/07-mvp-roadmap-and-open-questions.md index d653fc8..e0f419a 100644 --- a/docs/concept/07-mvp-roadmap-and-open-questions.md +++ b/docs/concept/07-mvp-roadmap-and-open-questions.md @@ -17,9 +17,9 @@ Build in thin vertical slices, each end-to-end on the example — on the foundat | 2 | Generic anchors + implementation binding + spec↔test linkage + `verifies` edges (anchored `claim`). | | 3 | Core conformance + honesty checks: referential integrity, duplicate IDs, honest readiness (the readiness floor), orphan detection, `verifies` linkage, authoring-shape honesty. CI gate. | | 4 | The agent surface (the `reader` — a few trusted accessors: entry adapters + impact) + the Design Review / one generated read-only view, both fully derived. | -| 5 | Polish: CLI (`sdp build`, `sdp validate`, maybe `explain`/`search`), error messages, the documented example, and a "regenerate from clean repo" determinism test. | +| 5 | Polish: the CLI surface resolved (`build` · `validate` · `view` — `explain`/`search` stay below the second-caller bar, `06` §3), one diagnostic rendering rule (location from the finding's structured fields; first contact fails clean), the documented example walkthrough (`examples/checkout-v1/README.md`), and the clean-repo determinism test (the full pipeline at a different absolute path is byte-identical). | -Package: a single **`@libar-dev/software-delivery-protocol`** (DSL + types, anchors, graph + reader/query API, `ts-morph` extractor, core checks, one view generator, CLI). Internal subpackage boundaries are a later concern, not decided now. +Package: a single **`@libar-dev/software-delivery-protocol`** (DSL + types, anchors, graph + reader/query API, `ts-morph` extractor, core checks, one view generator, CLI). Internal subpackage boundaries are a later concern, deliberately undecided. > Tip: write the example specs and anchored code **first**. That forces the DSL and extractor to be usable before they are "finished." @@ -38,7 +38,7 @@ Package: a single **`@libar-dev/software-delivery-protocol`** (DSL + types, anch ## 2. CORE vs ASPIRATIONAL map -**CORE (MVP):** Phase 0 — the protocol as code; TS Spec DSL; the three descriptors (`kind` · `altitude` · `readiness`); sections (shape only); stable IDs; generic anchors; `ts-morph` one-graph extractor; honest `claim` (declared / anchored / minimal advisory inferred); core conformance + honesty checks; readiness floors (through `ready`); delivery facts (`implemented` / `has-verifier`) derived, never authored; the agent surface (reader) + `graph.json` as AI context; the Design Review / one read-only view; bidirectional spec↔test trace; determinism + `--check-clean`. The **entire trust model** ships at MVP. The delivery-process **lenses** — discipline-as-filter, release/baseline as git-tag projections (`06` §6) — come essentially **for free** off the graph + git tags; they are not separate built features and get no dedicated slice. +**CORE (MVP):** Phase 0 — the protocol as code; TS Spec DSL; the three descriptors (`kind` · `altitude` · `readiness`); sections (shape only); stable IDs; generic anchors; `ts-morph` one-graph extractor; honest `claim` (declared / anchored; the `inferred` category is designed-in, decoded by every consumer, and **ships empty** — its first producer is the aspirational impact graph, `06` §2); core conformance + honesty checks; readiness floors (through `ready`); delivery facts (`implemented` / `has-verifier`) derived, never authored; the agent surface (reader) + `graph.json` as AI context; the Design Review / one read-only view; bidirectional spec↔test trace; determinism + `--check-clean`. The **entire trust model** ships at MVP. The delivery-process **lenses** — discipline-as-filter, release/baseline as git-tag projections (`06` §6) — come essentially **for free** off the graph + git tags; they are not separate built features and get no dedicated slice. **ASPIRATIONAL:** runtime-observation overlay (the `observed` delivery fact; runtime observations, `Build`/`Deployment`/`Observation` nodes, `nfr-violated`); runtime-composition depth (Effect `R`, Awilix wiring, Fastify trees); Gherkin surface; harnesses + simulation; rich projections (LikeC4/OpenAPI/JSON-LD/SHACL); rich Spec Studio with scoped intent composition; AI slices + the **MCP surface** (designed-in, deferred build) + GraphRAG; architecture-enforcement checks; a fuller impact graph; incremental builds/caching; full CLI; `--lenient` ratchet; multi-tenant/multi-repo/polyglot. @@ -58,7 +58,7 @@ These are out of the first slice. Each is genuinely deferred, and the model in ` 6. **Rich projections + heavy AI tooling.** No LikeC4/OpenAPI/JSON-LD/SHACL; no dedicated slice generator or MCP surface. *Rationale:* the agent surface + graph JSON is sufficient structured context at MVP scale. 7. **Architecture-enforcement checks.** No forbidden-dependency validators, no ts-arch tests, no custom `defineRule`. Keep only core graph invariants. *Rationale:* a whole validation competency; the small bounded context does not need it yet. 8. **A fuller impact graph.** The MVP ships **file-level** impact/blast-radius off the curated graph (`06` §2 boundary); the exhaustive language-server-grade impact graph (cross-package, symbol-level identity, `bySymbol`, drift/fan-in tooling) is deferred. *Rationale:* the curated surface proves the thesis first, and file-level reach needs no symbol index. -9. **Incremental builds / caching / sharding & full CLI** (evidence, migrate, ai subcommands). Full rebuild per run; `sdp build` + `sdp validate` (+ maybe `explain`/`search`) proves the loop. *Rationale:* fine at MVP scale. +9. **Incremental builds / caching / sharding & full CLI** (evidence, migrate, ai subcommands). Full rebuild per run; `sdp build` + `sdp validate` + `sdp view` prove the loop — `explain`/`search` stay below the second-caller bar (`06` §3). *Rationale:* fine at MVP scale. --- @@ -66,9 +66,9 @@ These are out of the first slice. Each is genuinely deferred, and the model in ` Deferred deliberately; recorded so they are not lost. None blocks the MVP. -- **Derived-readiness banner timing.** Floor enforcement (the stated rung is checked) is MVP; the explicit "stated `defined`, derived `scoped`" banner can be a fast follow. *(`05` §3.)* +- **Derived-readiness banner timing (resolved, recorded here for traceability).** Floor enforcement and the banner both ship in the MVP: the Design Review renders stated readiness beside the structurally-reached floor and names the first unmet clause — cheaply, because the floor evaluator reports which clause fails. *(`05` §3.)* - **Impact-graph depth (resolved, recorded here for traceability).** The boundary is decided, not open: **file-level** impact ships in the MVP (`git diff` → `byFile` → a curated-graph walk gives changeset blast-radius with no symbol index, surfacing an explicit `coverage-unknown` item for any changed file that has no anchor so a too-small set is never mistaken for complete), while the **exhaustive** impact graph — `bySymbol`, symbol-level identity, cross-package find-all-usages, drift/fan-in tooling — is deferred (Iterate). What remains genuinely open is only *when* the exhaustive graph earns its way in, driven by measured pain (§5), never the MVP boundary itself. *(`06` §2.)* -- **Inline-vs-centralized anchor semantics.** Anchors carry no intent in the MVP. How much *structural* semantics an anchor may carry (beyond `id`/`satisfies`/`component`) is left configurable later. *(`04` §2.)* +- **Inline-vs-centralized anchor semantics.** Anchors carry no intent in the MVP. How much *structural* semantics an anchor may carry beyond the landed binding contract (`id` · optional `label` · one `satisfies`/`verifies` target) — e.g. a future `component`/`implements` — is left configurable later. *(`04` §2.)* - **Graph-DB timing.** File-based until measured traversal pain; the schema is designed to map to a property graph later. *(`03` §4.)* - **Trace-link recovery.** Permitted later only as an assistive *suggestion* engine (the impact graph's "propose candidates" assist role), never a declared edge — bounded permanently by P10. *(`01`, `06` §2.)* - **When (if ever) Gherkin / harnesses / evidence become CORE.** Driven by measured pain after the MVP loop holds, not by the roadmap. @@ -88,40 +88,45 @@ The point of the principle-led core is that each of these slots in cleanly, with --- -## 6. Forward-looking acceptance criteria (seeded by the Phase-0 full-MVP review) - -Recorded here so the full-scope lens isn't lost; each is honesty-posture-aligned and maps to a slice's "done." -These came out of the Phase-0 hardening review and were routed here (rather than into that code-only plan) so they -land in the roadmap at the right altitude. Ordering reflects the synthesis's priority. - -- **① Authoring ergonomics — the headline forward risk; a named Slice-2 concern.** There is *no - authoring-ergonomics workstream* anywhere in `00`–`07` today (the MVP CLI is just `build`/`validate`), yet if - authoring feels heavy, authors (human **and** agent) avoid the system or overfit specs to satisfy tooling. The - first lever — **typed sections** (autocomplete + shape guardrails) — **landed in the Phase-0 hardening - (the typing law, MD-11)**; the next are great error messages and `sdp validate --watch`; later `sdp new spec` / - `sdp explain`. Threads back to the anti-padding rule: make *dishonesty* fail without rewarding - low-signal filler (a floor to clear, never a quota to fill). -- **② Golden-graph fixture — at Slice 1; keep it distinct from `--check-clean`.** Adopt **both**, labeled - distinctly: a **determinism self-check** (`03` §2 — rebuild twice, assert **byte-identical**; a self-comparison, - **never** a diff against a committed `generated/` artifact, which is gitignored, L8) **and** a **correctness - oracle** (a committed `fixtures/order-management/expected/graph.json` — "did the extractor produce the *right* - graph," legitimate because it lives in `fixtures/`, not `generated/`). Make paths **repo-relative / POSIX**, and - decide consciously whether **line numbers** enter the golden (deterministic, but brittle to unrelated edits). -- **③ Derived-readiness banner in the MVP view — at Slice 4; its blocker is cleared.** *"Stated readiness: - ready · Structural floor reached: defined · Problem: blocking open question."* Teaches the core honesty - concept (stated, then checked); cheaply enabled by the floor evaluator, which reports *which* clause - fails. The old blocker — the floor reading open questions from the wrong section — was fixed in the - Phase-0 hardening (the open-questions home, MD-9 — the floor reads `intent.openQuestions`). -- **④ `implemented` is a UI hazard — at Slice 4, view-label only.** Model semantics are settled (DECISIONS MD-7: - binding/existence, never liveness). Keep the internal fact name `implemented` (it powers the `implemented ∧ - ¬ready` drift query), but render binding language in views: *"Implementation binding: present / Verifier binding: +## 6. Acceptance criteria of record (the full-MVP lens) + +Kept here so the full-scope lens is never lost; each criterion is honesty-posture-aligned and is stated as +the standing invariant plus where its protection lives. + +- **① Authoring ergonomics — the headline forward risk.** If authoring feels heavy, authors (human **and** + agent) avoid the system or overfit specs to satisfy tooling. Two levers ship in the MVP: **typed sections** + (autocomplete + shape guardrails — the typing law, MD-11) and the one diagnostic rendering rule (location + rendered from the finding's structured fields; first contact fails clean — §1, Slice 5). The remaining + levers are genuinely forward-looking: `sdp validate --watch`; later `sdp new spec` / `sdp explain` (below + the second-caller bar, `06` §3). Threads back to the anti-padding rule: make *dishonesty* fail without + rewarding low-signal filler (a floor to clear, never a quota to fill). +- **② A golden correctness oracle, kept distinct from the determinism self-checks.** Both exist, labeled + distinctly, never conflated. The **correctness oracle** — "did the extractor produce the *right* graph" — + is the committed golden fixture: `test/fixtures/checkout-v1/expected-graph.json` plus the + `expected-design-review/` golden tree (legitimate because it lives under `test/fixtures/`, never gitignored + `generated/`, L8; paths repo-relative / POSIX; binding line numbers deliberately included — the line *is* + the binding location, `03` §1). The **determinism self-checks** — "is the output a pure function of the + repo" — live separately in the CLI tests: `--check-clean` (two independent extractions, byte-identical), + delete-`generated/`-and-rebuild, and the clean-repo determinism test (the full pipeline at a different + absolute path is byte-identical) — each a self-comparison, **never** a diff against a committed + `generated/` artifact (`03` §2). +- **③ The derived-readiness banner ships in the Design Review.** The view renders stated readiness beside + the structurally-reached floor and names the first unmet clause — cheap because the floor evaluator + reports *which* clause fails (`05` §3, with the open-questions home, MD-9: the floor reads + `intent.openQuestions`). The banner fires only in the dishonest direction — derived at-or-above stated is + ordinary information, because the floor is a floor, never a quota that nags upward. It teaches the core + honesty concept: stated, then checked. +- **④ `implemented` is a UI hazard — view-label only.** Model semantics are settled (binding, never + liveness — MD-7): the internal fact name stays `implemented` (it powers the `implemented ∧ ¬ready` drift + query), and views render binding language instead: *"Implementation binding: present / Verifier binding: present / Runtime observation: not tracked."* -- **`coverage-unknown` — already a settled model commitment (binding, never liveness — MD-7 / §4 above); make it Slice-4 acceptance.** - File-level blast-radius reports changed-but-unanchored files as `coverage-unknown`, never silently - under-reporting. The only add is promoting it from design note → explicit Slice-4 acceptance criterion. -- **The MVP acceptance checklist, mapped across Slices 1–5:** spec extraction · anchor extraction · claim honesty · - readiness honesty · delivery facts · traceability · determinism · view — with three sharpenings: (a) *"ready spec - with blocking open questions fails"* is the regression test to add **after** H2 (locked early by an H8 fixture - stub); (b) extend *"rejects non-static envelope fields"* to *"the example fixture survives static extraction with - **no dropped sections**"* (envelope is clean; sections were the H1 risk, now fixed); (c) *"extracts one api - anchor"* is the H10 gap (Slice 2). +- **⑤ `coverage-unknown` is acceptance, never a design note.** File-level blast-radius (the reader, `06` §2) + reports a changed-but-unanchored file as an explicit `coverage-unknown` item, never silently + under-reporting — test-pinned, so a too-small reach set is a caught regression, not a rendering choice. +- **The MVP acceptance checklist** — spec extraction · anchor extraction · `claim` honesty · readiness + honesty · delivery facts · traceability · determinism · view — with three sharpenings, each pinned: + (a) a stated-`ready` spec with a blocking `intent.openQuestions` entry fails the readiness-floor honesty + check (a should-fail fixture pins it); (b) the example survives static extraction with **no dropped + sections** (the drops-no-sections extraction test pins it — rejecting non-static envelope fields alone is + not enough); (c) the extractor extracts an `api:` anchor (the example's `api:orders.post` assertion pins + it). diff --git a/docs/concept/DECISIONS.md b/docs/concept/DECISIONS.md index 2bc5604..4f1b0e3 100644 --- a/docs/concept/DECISIONS.md +++ b/docs/concept/DECISIONS.md @@ -28,7 +28,7 @@ specs at the post-Slice-1 fold, under the future spec id reserved here. | MD-4 | one primitive, named coordinates | durable | `spec:protocol.decisions.one-primitive` | | MD-5 | the protocol naming | durable | `spec:protocol.decisions.protocol-naming` | | MD-7 | binding, never liveness | durable | `spec:protocol.decisions.binding-not-liveness` | -| MD-8 | the generic `codeAnchor` | folds at Slice 2 → doc-comment on the `codeAnchor` builder | — | +| MD-8 | the generic `codeAnchor` | **folded** (Slice 2) → doc-comment on the `codeAnchor` builder (`src/model/anchors.ts`) | — | | MD-9 | the open-questions home | folds at the fold (lives in `sections.ts`, the floor, `02` §3) | — | | MD-10 | content-only sections | durable | `spec:protocol.decisions.content-only-sections` | | MD-11 | the typing law | durable | `spec:protocol.decisions.typing-law` | @@ -184,7 +184,7 @@ brief have since been **deleted** (consolidated); the **sole canonical base is > review artifacts into tracked `reviews/`. The grill (`plans/03`) now opens onto a lean base and only > genuinely-open decisions. -### MD-8 — Generic-anchor DSL shape: one `codeAnchor` over the implementation-flavored code namespaces [ACCEPTED 2026-06-10] +### MD-8 — Generic-anchor DSL shape: one `codeAnchor` over the implementation-flavored code namespaces [ACCEPTED 2026-06-10 · FOLDED at Slice 2 — the rationale lives on the `codeAnchor` builder doc-comment (`src/model/anchors.ts`); kept as the historical record] **Decision.** Generalize `anchorImplementation` into a **`codeAnchor`** builder (plus branded id) accepting the implementation-flavored code namespaces — **`impl` / `api` / `component`** — so a *generic* anchor can bind any code location (class, function, route, module) as the base requires. @@ -301,7 +301,7 @@ silent-skip failure mode survives). **Execution.** Wave B (plan 02 H5), together with the MD-12 floor rewrite — one change, since the table being rewritten is the table being collapsed. -### MD-14 — One validation path, through the one graph; `AuthoredModel` retires as a public seam [ACCEPTED 2026-06-10 · direction; executes Slice 1/3] +### MD-14 — One validation path, through the one graph; `AuthoredModel` retires as a public seam [ACCEPTED 2026-06-10 · EXECUTED — the extractor landed at Slice 1, the graph-validator re-key at Slice 3; `AuthoredModel` is deleted and `validateGraph` is the sole validation seam] **Decision.** When the extractor lands, validators consume **the extractor's output** — one path: source → extract (static reification, P5) → graph (in memory) → conformance + honesty checks; `sdp validate` = `sdp build` + checks. `AuthoredModel` is demoted to (at most) an extractor-internal intermediate — never a diff --git a/docs/concept/ubiquitous-language.md b/docs/concept/ubiquitous-language.md index 69a302c..ad48414 100644 --- a/docs/concept/ubiquitous-language.md +++ b/docs/concept/ubiquitous-language.md @@ -170,7 +170,7 @@ artifact** — approval provenance is git-native, never an authored primitive). - **"epistemic boundary"** is a *working name* for the humans-assert-intent / machines-assert-structure division (`01`); a friendlier Studio-facing name is a minor open item. -- **`ref()`** in the DSL is today a **spec-only** reference builder wearing a generic name (it rejects +- **`ref()`** in the DSL is a **spec-only** reference builder wearing a generic name (it rejects `pack:`/`doc:` targets) — documented on the export (`src/ids.ts`). Consequently `decidedBy` → an external `doc:` ADR is a **named deferral** (MD-16, stated in `02` §6); revisit when `doc:`-target relations or pack-targeting arrive. @@ -193,8 +193,8 @@ artifact** — approval provenance is git-native, never an authored primitive). - **Locked usage:** readiness is **"stated/asserted," never "claimed"** ("claim" is reserved for the `claim` taxonomy) · the meta-model defines the **contract**, **instances conform**; "govern"/"police" retired · checks are **conformance checks + honesty checks** · **pre-graph** = upstream of graph derivation in the - one validation path (the authored layer before the extractor runs) — fences stand-in checks, never a - second validation path (one validation path, MD-14). + one validation path (the authored layer before the extractor runs) — a layer checks never live in: + validators consume the one graph only, never a second validation path (one validation path, MD-14). - **Resolved (MD-15):** authored Spec files carry the **`.sdp.ts`** extension (never `.spec.ts`, which every JS test-runner default glob executes); the model name `Spec` itself was always settled — only the file serialization changed. diff --git a/examples/checkout-v1/README.md b/examples/checkout-v1/README.md new file mode 100644 index 0000000..e979687 --- /dev/null +++ b/examples/checkout-v1/README.md @@ -0,0 +1,117 @@ +# checkout-v1 — the worked example + +The MVP bounded context: **Order Management**, modeled as `pack:checkout-v1` — nine `Spec`s, one +`Pack`, three anchors. It exists to prove the loop end-to-end on one small, honest slice: author +delivery intent as typed code, bind the implementing code and tests with anchors, derive **the one +graph**, run the conformance + honesty checks, and read the generated Design Review. It is also +the tracer bullet: if this example stops typechecking or extracting, the DSL or the extractor is +wrong — not the example. + +This walkthrough shows what is here and how to watch the trust model react. The concepts live in +[`docs/concept/`](../../docs/concept/README.md); the vocabulary in the +[ubiquitous language](../../docs/concept/ubiquitous-language.md). Nothing here is restated — only +pointed at. + +## The layout + +- **`specs/`** — the **authored model**: one `Spec` per `*.sdp.ts` file, plus the pack manifest + (`checkout.pack.sdp.ts`). The familiar delivery nouns appear as **named coordinates on the one + primitive**, never separate types: a `behavior` epic (`order-management`), a `behavior` feature + (`create-order`), two `example` stories (`valid-cart`, `invalid-cart`), two `rule`s, one + `constraint` (the latency NFR), one `model` (the domain vocabulary), and one `decision` record. + Every spec states its own `readiness`; the checks only verify the stated rung is structurally + earned. +- **`src/`** — the implementation, carrying two **anchors**: `impl:orders.create-order-use-case` + on the use-case and `api:orders.post` on the route. An anchor is a binding only — "this code + location is the implementation binding for this Spec ID" — never intent; each yields a + `satisfies` edge with `claim: "anchored"`. +- **`test/`** — the runner test plus the **test anchor** (`specTest`) that binds it to + `spec:orders.create-order.valid-cart`. That binding is what makes the valid-cart example an + **enabled verifier**, which in turn confers the derived `has-verifier` delivery fact on its + target. + +## The walk + +Build the CLI once from the repo root, then run the pipeline (each command subsumes the previous +stage): + +```sh +npm run build +node ./dist/cli/sdp.js build examples/checkout-v1 # extract → generated/graph.json +node ./dist/cli/sdp.js validate examples/checkout-v1 # build + conformance & honesty checks +node ./dist/cli/sdp.js view examples/checkout-v1 # validate + the Design Review +``` + +`build` prints the extraction summary and writes the one graph — flat nodes and edges, every one +carrying its `claim` (`declared` / `anchored` / `inferred`, never collapsed): + +``` +9 specs · 1 packs · 3 anchors → 13 nodes · 25 edges (0 errors, 0 warnings) +``` + +`validate` runs the checks over that graph — one validation path — and reports **0 errors and +exactly 1 warning**: + +``` +specs/orders/create-order-invalid-cart.sdp.ts — [warning] conformance/verifies-linkage — +Example "spec:orders.create-order.invalid-cart" declares verifies → "spec:orders.create-order" +but is not an enabled verifier — no test anchor binds it … +``` + +The warning is **deliberately kept**. `invalid-cart` declares that it verifies its parent, but no +test anchor binds it yet — the spec↔test trace is incomplete, and the graph says so instead of +pretending. This is the honesty posture in one line: a surfaced absence is informative, never a +gate (the exit code stays 0). + +`view` regenerates `generated/design-review/` wholesale — an index plus one page per spec and +pack, all pure projections of the graph. Open +`generated/design-review/spec/orders.create-order.md` and look for: + +- **binding language, never liveness** — "Implementation binding: present · Verifier binding: + present · Runtime observation: not tracked"; +- **`claim` cues** — the example's own `verifies` is `[declared]`; the test anchor's is + `[anchored]`; the enabled verifier (`valid-cart`) is distinguished from the unenabled one + (`invalid-cart`); +- **stated vs derived readiness** — every spec here states `defined` while structurally clearing + `ready`; the honest direction renders as plain header information, no banner; +- **Relations & impact (one hop)** — the relation list read as the blast radius of changing this + spec. + +Determinism is checkable, not promised: `--check-clean` on any command runs the pipeline twice +independently and fails on a single divergent byte — `npm run check:example` gates CI on exactly +this over this example. Deleting `generated/` and rerunning reproduces the same bytes, and so +does the pipeline run from a copy at a different absolute path — both pinned in the test suite, +which CI also gates. + +## Break it on purpose + +The fastest way to understand the checks is to trip them. Each experiment is one edit; revert +with `git checkout -- examples/checkout-v1` afterwards. + +- **Dangle a reference.** In `specs/orders/create-order.sdp.ts`, misspell the `refines` target + (e.g. `spec:orders.order-managment`). `validate` exits 1 with + `conformance/referential-integrity` — and suggests the id you meant. +- **State readiness you haven't earned.** Add a blocking open question to `create-order`'s + intent — `openQuestions: [{ question: "Do guest carts create orders?", blocking: true }]` — + while it states `defined`. `validate` exits 1 with `honesty/readiness-floor`, naming the + failing clause (`no-blocking-open-questions`): readiness is stated by the author, checked + against the floor. +- **Unbind the test.** Delete the `specTest` anchor from + `test/orders/create-order.valid-cart.test.ts`. The valid-cart example stops being an enabled + verifier, the parent loses its verifier binding, and a second `conformance/verifies-linkage` + warning appears — still exit 0, because a missing verifier is a surfaced gap, never a gate. +- **Hand-author a delivery fact.** Add `"has-verifier": true` inside any section of a spec. The + closed section types reject it (`npm run typecheck:examples` fails), and even smuggled past the + types it fails `validate` with `honesty/authoring-shape` — delivery facts are derived, never + authored. + +## Where the concepts live + +| Concept | Read | +| ------------------------------------------------------ | ---------------------------------------------------------------------------------- | +| the vocabulary (every term used above) | [ubiquitous language](../../docs/concept/ubiquitous-language.md) | +| the `Spec` primitive, descriptors, sections, relations | [`02` core model](../../docs/concept/02-core-model.md) | +| the one graph, determinism, the `claim` taxonomy | [`03` the one graph](../../docs/concept/03-the-one-graph.md) | +| the DSL and anchors (authoring & binding) | [`04` authoring & binding](../../docs/concept/04-authoring-and-binding.md) | +| the checks and the readiness floor | [`05` validation & honesty](../../docs/concept/05-validation-and-honesty.md) | +| the reader, the Design Review, the agent surface | [`06` consumers & projections](../../docs/concept/06-consumers-and-projections.md) | diff --git a/examples/checkout-v1/model.ts b/examples/checkout-v1/model.ts deleted file mode 100644 index af8d69e..0000000 --- a/examples/checkout-v1/model.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { AuthoredModel } from "@libar-dev/software-delivery-protocol"; - -import { checkoutV1Pack } from "./specs/checkout.pack.sdp.js"; -import { orderLifecycleDecisionSpec } from "./specs/decisions/order-lifecycle.sdp.js"; -import { createOrderInvalidCartSpec } from "./specs/orders/create-order-invalid-cart.sdp.js"; -import { createOrderValidCartSpec } from "./specs/orders/create-order-valid-cart.sdp.js"; -import { createOrderSpec } from "./specs/orders/create-order.sdp.js"; -import { orderInventoryRuleSpec } from "./specs/orders/order-inventory-rule.sdp.js"; -import { orderLatencyConstraintSpec } from "./specs/orders/order-latency-constraint.sdp.js"; -import { orderManagementSpec } from "./specs/orders/order-management.sdp.js"; -import { orderModelSpec } from "./specs/orders/order-model.sdp.js"; -import { orderTotalRuleSpec } from "./specs/orders/order-total-rule.sdp.js"; -import { createOrderUseCaseAnchor } from "./src/orders/create-order.use-case.js"; -import { createOrderValidCartTest } from "./test/orders/create-order.valid-cart.test.js"; - -export const checkoutV1Model: AuthoredModel = { - specs: [ - orderManagementSpec, - createOrderSpec, - createOrderValidCartSpec, - createOrderInvalidCartSpec, - orderTotalRuleSpec, - orderInventoryRuleSpec, - orderLatencyConstraintSpec, - orderModelSpec, - orderLifecycleDecisionSpec, - ], - packs: [checkoutV1Pack], - anchors: [createOrderUseCaseAnchor, createOrderValidCartTest], -}; diff --git a/examples/checkout-v1/src/orders/create-order.route.ts b/examples/checkout-v1/src/orders/create-order.route.ts new file mode 100644 index 0000000..6d054bd --- /dev/null +++ b/examples/checkout-v1/src/orders/create-order.route.ts @@ -0,0 +1,28 @@ +import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; + +import { createOrderFromCart } from "./create-order.use-case.js"; +import type { CartInput, CreatedOrder, InventorySnapshot } from "./create-order.use-case.js"; + +export const createOrderRouteAnchor = codeAnchor({ + id: codeAnchorId("api:orders.post"), + label: "POST /orders", + satisfies: ref("spec:orders.create-order"), +}); + +export interface CreateOrderRequest { + readonly cart: CartInput; + readonly inventory: InventorySnapshot; +} + +export type CreateOrderResponse = + | { readonly status: 201; readonly order: CreatedOrder } + | { readonly status: 422; readonly error: string }; + +/** A deliberately thin handler: validation and creation live in the use case it delegates to. */ +export function postOrders(request: CreateOrderRequest): CreateOrderResponse { + try { + return { status: 201, order: createOrderFromCart(request.cart, request.inventory) }; + } catch (error) { + return { status: 422, error: error instanceof Error ? error.message : "Invalid cart." }; + } +} diff --git a/examples/checkout-v1/src/orders/create-order.use-case.ts b/examples/checkout-v1/src/orders/create-order.use-case.ts index 6a1b1c5..ff8bc18 100644 --- a/examples/checkout-v1/src/orders/create-order.use-case.ts +++ b/examples/checkout-v1/src/orders/create-order.use-case.ts @@ -1,4 +1,4 @@ -import { anchorImplementation, implAnchorId, ref } from "@libar-dev/software-delivery-protocol"; +import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; export interface CartLine { readonly productId: string; @@ -20,8 +20,8 @@ export interface CreatedOrder { readonly lines: readonly CartLine[]; } -export const createOrderUseCaseAnchor = anchorImplementation({ - id: implAnchorId("impl:orders.create-order-use-case"), +export const createOrderUseCaseAnchor = codeAnchor({ + id: codeAnchorId("impl:orders.create-order-use-case"), label: "createOrderFromCart", satisfies: ref("spec:orders.create-order"), }); diff --git a/examples/checkout-v1/test/orders/create-order.valid-cart.test.ts b/examples/checkout-v1/test/orders/create-order.valid-cart.test.ts index d53ac37..100efa4 100644 --- a/examples/checkout-v1/test/orders/create-order.valid-cart.test.ts +++ b/examples/checkout-v1/test/orders/create-order.valid-cart.test.ts @@ -1,7 +1,33 @@ +import { describe, expect, it } from "vitest"; + import { ref, specTest, testAnchorId } from "@libar-dev/software-delivery-protocol"; +import { createOrderFromCart } from "../../src/orders/create-order.use-case.js"; + +// The binding anchor and the runner test live side by side (`04` §2): the anchor is what makes +// the valid-cart example an enabled verifier; the test below is its executable half, mirroring +// the example's Given/When/Then. export const createOrderValidCartTest = specTest({ id: testAnchorId("test:orders.create-order.valid-cart"), label: "valid cart verifies the create-order happy path", verifies: ref("spec:orders.create-order.valid-cart"), }); + +describe("create-order: valid cart", () => { + it("creates an order with a stable id, the cart-math total, and the original cart lines", () => { + const cart = { + customerId: "customer-7", + lines: [ + { productId: "product-a", quantity: 2, unitPrice: 25 }, + { productId: "product-b", quantity: 1, unitPrice: 50 }, + ], + }; + + const order = createOrderFromCart(cart, { "product-a": 5, "product-b": 3 }); + + expect(order.orderId).toBe("order-customer-7"); + expect(order.customerId).toBe("customer-7"); + expect(order.total).toBe(2 * 25 + 1 * 50); + expect(order.lines).toEqual(cart.lines); + }); +}); diff --git a/jtbd-stories/01-capture-and-evolve-intent.md b/jtbd-stories/01-capture-and-evolve-intent.md index fd08215..4c6f237 100644 --- a/jtbd-stories/01-capture-and-evolve-intent.md +++ b/jtbd-stories/01-capture-and-evolve-intent.md @@ -16,8 +16,8 @@ The job here is to get a thought into the system and let it grow without ever fo **Acceptance criteria:** 1. A spec can be created at `readiness: "idea"` with only `id`, `title`, `kind`, `altitude`, and either `intent.outcome` or a parent relation — nothing else is required. -2. The new spec lives in `/specs/**/*.sdp.ts` as committed code, immediately part of the single source of truth — no status field, ticket, or external tool is needed for it to "exist." -3. Open questions can be attached (`intent.openQuestions`) without resolving them and without blocking capture; only questions explicitly marked `blocking` constrain later promotion. +2. The new spec lives in a `*.sdp.ts` file under the extraction root (conventionally `/specs/`) as committed code, immediately part of the single source of truth — no status field, ticket, or external tool is needed for it to "exist." +3. Open questions can be attached (`intent.openQuestions`) without resolving them and without blocking capture; only questions explicitly marked `blocking` constrain stating `defined`/`ready` later. 4. The spec is valid at its lowest readiness — the build never demands rules, anchors, or tests to accept an idea. 5. The spec source is static, side-effect-free data (a "JSON file that TypeScript happens to validate"), so the extractor reifies it deterministically. 6. Two people capturing two ideas never collide on identity, because each spec carries a stable, namespaced ID (e.g. `spec:orders.create-order`); a duplicate ID is a loud build error, never a silent merge. diff --git a/jtbd-stories/02-bind-code-to-intent.md b/jtbd-stories/02-bind-code-to-intent.md index 696332d..cc9b33b 100644 --- a/jtbd-stories/02-bind-code-to-intent.md +++ b/jtbd-stories/02-bind-code-to-intent.md @@ -12,13 +12,13 @@ The job here is to connect real implementation to the spec that justifies it — > **When** I write a class, function, route, or module that realises part of a spec, **I want to** anchor it to the spec's ID right where it lives, **so I can** make the implementation self-describing without maintaining a separate mapping table. -**Essence:** An anchor is a one-way pointer from code to intent. It is the anchored layer of the graph — it carries identity and structural bindings, never intent. +**Essence:** An anchor is a one-way pointer from code to intent. It is the anchored layer of the graph — it carries identity and a binding, never intent. **Acceptance criteria:** -1. Any significant code construct can carry an anchor naming the spec(s) it `satisfies` and its `component` — regardless of the framework it is built on (no Effect/Awilix/Fastify knowledge required). +1. Any significant code construct can carry an anchor naming the spec it `satisfies` (one binding target per anchor; two bindings are two anchors) — regardless of the framework it is built on (no Effect/Awilix/Fastify knowledge required). 2. More than one interchangeable syntax is available (decorator on a class, JSDoc on a function, anchor-constant) so the anchor never fights the code's shape; a team picks one style. 3. The anchor is metadata only: removing the extractor changes nothing about how the code runs. -4. An anchor carries identity and structural bindings (`id`, `satisfies`, `component`, `implements`) — and is **forbidden** from carrying intent, readiness, behaviour, or verification, which live only on the spec. +4. An anchor carries identity and its one binding (`id` · optional `label` · a `satisfies`/`verifies` target; richer structural bindings like `component`/`implements` are aspirational) — and is **forbidden** from carrying intent, readiness, behaviour, or verification, which live only on the spec. 5. The anchor points one way: code → spec, never spec → code; it produces **anchored**-`claim` edges, kept distinct from the `declared` relations authored on specs. 6. Adding an anchor is a small, local edit reviewable in the same diff as the code. 7. A missing anchor on a designated "significant" construct is catchable as a lint signal, so meaningful code does not silently fall out of the graph — useful, not load-bearing. diff --git a/jtbd-stories/04-keep-it-honest.md b/jtbd-stories/04-keep-it-honest.md index 827d4dd..5319c5f 100644 --- a/jtbd-stories/04-keep-it-honest.md +++ b/jtbd-stories/04-keep-it-honest.md @@ -39,7 +39,7 @@ A graph nobody trusts is worthless. The job here is to make the delivery state * **Acceptance criteria:** 1. Each readiness level has an explicit floor of required sections/relations: `idea` (id/title/kind/altitude + outcome-or-parent) → `scoped` → `defined` → `ready`. 2. A spec stating a level it does not satisfy is flagged, with the specific missing pieces named. -3. Low readiness is permissive — ideas and scoped specs are not punished for being incomplete; an open question only blocks promotion when it is explicitly marked `blocking`. +3. Low readiness is permissive — ideas and scoped specs are not punished for being incomplete; an open question only blocks stating `defined`/`ready` when it is explicitly marked `blocking`. 4. High readiness is strict and **structural**: the `ready` floor requires resolved relations, every `dependsOn`/`refines` target at least `defined`, no blocking open questions, and any anchors present resolving. **Delivery facts** (`implemented`, `has-verifier`) are *derived* from edges — never required by the floor, never an ingested pass/fail verdict. 5. A quality constraint must carry a machine-readable `target` (e.g. `p95 < 300ms`, not "fast enough") before its spec can state `defined` or higher. 6. The author's *stated* readiness and a *derived* readiness (computed from what the spec actually contains) can be compared, and a divergence (stated `defined`, derived `scoped`) is itself surfacable. diff --git a/jtbd-stories/05-see-and-share.md b/jtbd-stories/05-see-and-share.md index 1169922..f77d5f4 100644 --- a/jtbd-stories/05-see-and-share.md +++ b/jtbd-stories/05-see-and-share.md @@ -76,14 +76,14 @@ The graph is only valuable if humans and agents can actually consume it. The job **Phase:** MVP **References:** [06 — Consumers & Projections](../docs/concept/06-consumers-and-projections.md) (§5), [02 — Core Model](../docs/concept/02-core-model.md), [05 — Validation & Honesty](../docs/concept/05-validation-and-honesty.md) -> **When** a spec or `Pack` is mature enough to consider promoting, **I want to** review it in context — its neighbors, relations, `claim`/delivery badges, open questions, and gaps — **so I can** decide whether to state `ready`, with the structural floor visible but the judgment mine. +> **When** a spec or `Pack` is mature enough to consider stating `ready`, **I want to** review it in context — its neighbors, relations, `claim`/delivery badges, open questions, and gaps — **so I can** decide whether to state `ready`, with the structural floor visible but the judgment mine. -**Essence:** The **Design Review** is the flagship curated surface (`06` §5) and the human act it supports: reviewing a spec in its related set and *deciding* to state `ready`. It is **not** an automated gate — validators check only the structural readiness floor (`05` §3); promotion is a human's call, never a side effect of the review. This is where the "maturity gates implementation" discipline actually happens. +**Essence:** The **Design Review** is the flagship curated surface (`06` §5) and the human act it supports: reviewing a spec in its related set and *deciding* to state `ready`. It is **not** an automated gate — validators check only the structural readiness floor (`05` §3); stating a rung is a human's call, never a side effect of the review. This is where the "maturity gates implementation" discipline actually happens. **Acceptance criteria:** 1. A spec (or `Pack`) renders *in context* — neighbors, relations, `claim`/delivery badges — reusing the one generated view (JS-E1), so review needs no separate tool. 2. The review surfaces exactly what stands between the spec and the next rung: blocking open questions, unresolved relations, `dependsOn`/`refines` targets below `defined`, and `gap`s (missing verifier, unmeasured NFR target). -3. Stating `ready` is a deliberate human edit to the spec, checked against the `ready` floor (`05`) — the review **never** auto-promotes, and validators never adjudicate design quality. +3. Stating `ready` is a deliberate human edit to the spec, checked against the `ready` floor (`05`) — the review **never** states a rung on the author's behalf, and validators never adjudicate design quality. 4. A `Pack` can be reviewed as a unit, so coherence and cross-member tensions (shared terms, conflicting constraints) are visible at the group level, not just per spec. 5. Findings (the auto-generated design questions + findings table) resolve through the edit loop (Theme F) — there is no stored `Finding` type and no second store; re-running the build regenerates them. 6. The same review is reproducible from the graph at a commit, so two reviewers see the same context, and the decision is recorded as an ordinary git commit (the readiness change), not as review-tool state. diff --git a/jtbd-stories/06-edit-through-the-lens.md b/jtbd-stories/06-edit-through-the-lens.md index 2bda9cd..80d2a15 100644 --- a/jtbd-stories/06-edit-through-the-lens.md +++ b/jtbd-stories/06-edit-through-the-lens.md @@ -7,7 +7,7 @@ The view is a lens, not an editor. The job here is to let people drive a change ## JS-F1 ### Drive a change as scoped intent, not a patch -**Phase:** Iterate *(the underlying edit model — agent edits source → git → conformance checks — is MVP-true today; the in-view composer is the Iterate layer)* +**Phase:** Iterate *(the underlying edit model — agent edits source → git → conformance checks — is MVP-true; the in-view composer is the Iterate layer)* **References:** [06 — Consumers & Projections](../docs/concept/06-consumers-and-projections.md) > **When** I'm exploring a spec in the view and want to change it — add an example, tighten a target, resolve a question — **I want to** select the scope and state my intent so the view hands a clean, token-budgeted prompt to an AI agent that edits the source, **so I can** edit comfortably while the repo stays canonical and conformance checks stay the gate. diff --git a/jtbd-stories/07-trace-and-impact.md b/jtbd-stories/07-trace-and-impact.md index 40afa9c..6bb124b 100644 --- a/jtbd-stories/07-trace-and-impact.md +++ b/jtbd-stories/07-trace-and-impact.md @@ -81,7 +81,7 @@ Once everything is one graph, the payoff is navigation: what does a change touch **Acceptance criteria:** 1. The graph answers "which specs in this `Pack` (or across the graph) have no resolving `has-verifier`" directly, as a list, not a count. 2. The answer reflects the structural fact only — a verifier *exists and is enabled* — never a pass/fail verdict, which stays CI's (`02` §2, JS-G2). -3. `ready` specs missing a verifier are highlighted as the priority slice (the honest "designed, claimed done, but unverified" gap), distinct from low-readiness specs that are not expected to have one yet. +3. `ready` specs missing a verifier are highlighted as the priority slice (the honest "designed, stated done, unverified" gap), distinct from low-readiness specs that are not expected to have one yet. 4. Each gap is reachable to its spec in context (the Design Review, JS-E1/JS-E4), so planning a test starts from the spec, not a bare ID. 5. The result is stable across refactors because it is keyed on stable IDs and `verifies` edges, not test file locations. 6. The same query is available to an agent through the agent surface, so coverage planning can be handed to an agent as structured context. diff --git a/jtbd-stories/README.md b/jtbd-stories/README.md index aa0ee10..6d37b28 100644 --- a/jtbd-stories/README.md +++ b/jtbd-stories/README.md @@ -12,15 +12,7 @@ The consumers of this system are heterogeneous and evolving — domain engineers ## Founding Principle — One Graph -Everything else is downstream of this. If a story ever conflicts with it, the principle wins. - -1. **No second graph, ever.** There is one graph. We do not stand up a parallel store that can disagree with the repo. -2. **The repository is canonical; the graph is derived and regenerable.** Delete the graph and rebuild it byte-for-byte from the repo. -3. **The `claim` is never silently collapsed or promoted.** A *declared* fact, an *anchored* binding, and an *inferred* guess stay distinguishable forever. Inference never quietly becomes truth. -4. **Truth is authored as code in the repo.** Intent, structure, and relationships are authored as typed code committed alongside the implementation — not in an external tool. (The *graph* is derived from that authored code; see #2.) -5. **Git history is the event log.** Specs and code are the events; the graph and every view are projections of the repo at a commit. No bespoke event store. - -> The graph is a *projection of the repository at a commit*. Change the repo, regenerate the projection. Nothing to sync, nothing to reconcile, nothing to trust beyond `git` and the code. +Everything else is downstream of this. If a story ever conflicts with it, the principle wins. In one breath: there is **one graph**, derived and regenerable from the canonical repo; truth is authored as code; the `claim` is never silently collapsed or promoted; git history is the event log. The canonical five-point statement lives in the [concept README](../docs/concept/README.md#the-founding-principle--one-graph) — stated there, only pointed at from here. --- @@ -78,7 +70,7 @@ The MVP target is one bounded context — Order Management, `pack:checkout-v1`, --- -## Out of scope for now (deliberately) +## Out of the MVP (deliberately) To keep the essence clean, these are *not* in the MVP and are intentionally absent from the job stories above. The model accommodates the deferred ones without refactoring the core. diff --git a/package-lock.json b/package-lock.json index 66e7941..ae68bf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "@libar-dev/software-delivery-protocol", "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "ts-morph": "~28.0.0" + }, "bin": { "sdp": "dist/cli/sdp.js" }, @@ -1079,6 +1083,53 @@ "win32" ] }, + "node_modules/@ts-morph/common": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz", + "integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==", + "license": "MIT", + "dependencies": { + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.14" + } + }, + "node_modules/@ts-morph/common/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1706,6 +1757,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2071,7 +2128,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -2558,6 +2614,12 @@ "node": ">=6" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2606,7 +2668,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2981,7 +3042,6 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3054,6 +3114,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/ts-morph": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz", + "integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.29.0", + "code-block-writer": "^13.0.3" + } + }, "node_modules/tsup": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", diff --git a/package.json b/package.json index 5719366..e1536a6 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "check:temporal": "node ./check-temporal.mjs", - "check": "npm run check:temporal && npm run typecheck && npm run typecheck:examples && npm run lint && npm run format:check && npm test && npm run build" + "check:example": "node ./dist/cli/sdp.js view examples/checkout-v1 --check-clean", + "check": "npm run check:temporal && npm run typecheck && npm run typecheck:examples && npm run lint && npm run format:check && npm test && npm run build && npm run check:example" }, "devDependencies": { "@eslint/js": "^9.0.0", @@ -45,5 +46,8 @@ "typescript": "^5.0.0", "typescript-eslint": "^8.0.0", "vitest": "^2.0.0" + }, + "dependencies": { + "ts-morph": "~28.0.0" } } diff --git a/plans/06-slice1-extractor.md b/plans/06-slice1-extractor.md new file mode 100644 index 0000000..003bfc4 --- /dev/null +++ b/plans/06-slice1-extractor.md @@ -0,0 +1,305 @@ +# Plan 06 — Slice 1: the `ts-morph` extractor and the one graph + +> **Status: ✅ EXECUTED 2026-06-10** — all eight work items landed on `feature/extractor`; +> `npm run check` green end-to-end. The tracer bullet passed whole on the first run: the untouched +> example extracts with **zero findings** (no dropped sections), `validateAuthoredModel` on the +> extractor-fed model reports zero findings (the MD-14 re-point), and the graph is exactly the +> predicted shape — 10 nodes, 22 edges (13 declared + 9 `belongsTo`), zero delivery facts. +> `examples/checkout-v1/model.ts` is deleted; delete-`generated/`-and-rebuild through the built CLI +> is byte-identical (same SHA), and the emitted bytes equal the committed golden. +> +> **Execution deviations, all deliberate:** (1) the plan's risk register watched the temporal guard, +> but the gate that actually collided with the golden was **prettier** — `format` rewrote the +> committed oracle (short arrays collapse to one line; `JSON.stringify` always expands), breaking +> byte-equality with the serializer that owns all output bytes (§2.5), so +> `test/fixtures/checkout-v1/expected-graph.json` joined `.prettierignore` as a genre exemption +> (derived-then-committed artifact, like the lockfile and `reviews/`); (2) **two corpora beyond the +> plan's list** — `invalid-malformed-id` and `unrecognized-statement` — so each of the five pinned +> finding ids has its own should-fail/should-pass corpus, not just the three activations; +> (3) `Finding` gained optional `file`/`line` (additive, L9) — §1.3/§2.5 presupposed file:line on +> report findings without naming the field growth that delivers it in the one diagnostic currency; +> (4) one grammar precision left implicit in §2.3 is now pinned in code: an envelope enum field +> (`kind`/`altitude`/`readiness`) that reifies to a *static but non-member* literal is an envelope +> error — the typed envelope cannot carry it (in authored repos `tsc` rejects it first; the +> extractor must not trust that); (5) on a duplicated id, the **model** records every authored site +> (truthful record; the existing duplicate-ids validator also fires) while the **graph** excludes +> them all — it cannot be keyed on an ambiguous id. +> +> **Post-execution adversarial pass (Codex)** — three findings, all valid, all fixed: (1) the +> **published-bin no-op** — the entry guard suffix-matched `process.argv[1]` against +> `/dist/cli/sdp.js`, but Node keeps npm's `node_modules/.bin/sdp` symlink path in `argv[1]`, so an +> installed CLI exited 0 silently; the guard is now `isCliEntrypoint` — realpath-compare `argv[1]` +> against the module's own file, fail closed — unit-tested through a real symlink and verified +> end-to-end against the built binary. (2) the **committed hard-error corpora poisoned the +> documented default root** — `sdp build` from this repo's root swept `test/fixtures/extract/`'s +> `.sdp.ts` files and exited 1; discovery's contract stays deliberately config-free (P3), so the +> corpora stepped outside the extension contract instead: committed as `*.sdp.ts.txt` (the repo +> never carries an invalid `*.sdp.ts`) and materialized by the corpus tests into temp directories +> under their real names (`test/helpers/extract-corpus.ts`), keeping the extractor on its genuine +> file-reading path; the §2.8 tsconfig/eslint corpus exclusions became dead config and were +> removed; a regression test pins that the repo root builds cleanly as a default root. (3) a +> **failed build left the previous `graph.json` in place** — "not written" still read as current; +> a hard-error or diverging `--check-clean` build now removes the prior artifact (stale truth is no +> truth), and the success path writes temp-then-rename so a crash can never leave a truncated +> graph looking whole. +> +> **Next session: Slice 2 — generic anchors** (`codeAnchor`, MD-8; H10's api anchor) + +> implementation/test binding → `satisfies` and anchored `verifies` edges. +> +> **Spec anchors:** `03` (derivation, determinism, the `claim` taxonomy, the edge contract) · `04` §1 +> (the static-data constraint, the two tiers) · `05` §2 (one validation path, MD-14) · `07` §6 ② +> (golden-graph fixture + determinism self-check, kept distinct) and (b) (no dropped sections) · +> JTBD theme C (JS-C1 derive-one-graph, JS-C2 trust-every-claim, JS-C3 regenerate-pure-function). + +## Context + +Phase 0 shipped the protocol as code: the `Spec`/`Pack`/anchor builders, typed sections (the typing +law, MD-11), the floor table (floor-table-as-truth, MD-13), the pre-graph validators on the +`AuthoredModel` stand-in harness, an inert graph schema (`src/graph/schema.ts`, `0.1.0`), a CLI stub, +and the 9-spec + 1-pack + 2-anchor checkout-v1 example — all `*.sdp.ts` (the `.sdp.ts` extension, +MD-15). **No extractor exists**; `ts-morph` is not yet a dependency; the example feeds the validators +through hand-assembled imports (`examples/checkout-v1/model.ts`) — exactly the import-evaluation path +the one-validation-path decision (MD-14) retires once the extractor lands. + +Slice 1 builds the **`extractor`** — the producer, the *only* component that reads source (`03` §1) +— for the **declared layer only**: spec files and pack manifests. Anchors, `satisfies`/anchored +`verifies` edges, `CodeNode`s, and the inferred layer ride Slice 2; the graph-validator gate rides +Slice 3. + +## §1 — Decisions this plan pins (Representation, not DECISIONS.md entries) + +Each of these is forced or near-forced by the ratified base; none passes the ADR three-part test, so +the paper trail is this plan + git. Where `07` §6 ② asked for a *conscious* choice, this is it. + +1. **`Primitive` nodes carry their sections.** The graph is "the sole input every consumer reads" + (`03`); the Slice-3 floor checks read section evidence and the Slice-4 Design Review renders + section content, and neither may re-parse source (P2, DECISIONS R2). So the reified sections ride + the node, nested under one `sections` field (structural metadata stays flat and scannable; + content is fenced in one place). Section content preserves authored order — it is content, and + authored order is deterministic given the repo. +2. **Schema grows additively to `0.2.0`** (L9): `PrimitiveNode` += `title?` · `file` · `sections?`; + `PackNode` += `title?` · `framing?` · `modelRefs?` · `file`. `modelRefs` stays node **data** — the + `03` edge contract has no `modelRefs` edge type; pack coherence reads it at Slice 3 (F4). +3. **No line numbers in the graph at Slice 1.** Nodes carry `file` only (extraction-root-relative, + POSIX separators, no leading `./`). The golden fixture stays robust to unrelated edits; report + findings still carry file:line for messages (JS-C2 #7 is satisfied by `file` + the report). + Revisit at Slice 2, where a `CodeNode`'s line *is* the binding location. +4. **`title` is not a hard-error field.** The base's hard-error list (`03` §2) is deliberate: `id` · + `kind` · `altitude` · `readiness` · any relation target. A non-static `title` degrades with a + warning (omitted from the node) like section detail — the graph can be keyed and typed without it. +5. **Hard errors: report everything, emit nothing.** Extraction always completes and reports every + finding (L3 — one bad spec never poisons the rest); the offending spec is not extracted; with any + hard error present `sdp build` exits 1 and **does not write** `graph.json` (the emitted artifact + is all-or-nothing — a silently partial projection is the dishonesty the build exists to prevent). + Programmatic callers still get the partial in-memory graph + the full report. +6. **`AuthoredModel`'s public demotion waits for Slice 3.** This slice makes the extractor the + **sole in-repo producer** of `AuthoredModel` values from real corpora (the hand-assembly + `examples/checkout-v1/model.ts` is deleted), but the barrel exports stay: the floor table's + predicate signatures are keyed to `AuthoredModel`, and removing the exports now churns those + types twice — once now, once at the Slice-3 re-key that deletes the seam wholesale. The + doc-comment fence is updated to say so. (One validation path, MD-14: "Slice 1 — the extractor + feeds the floor checks; Slice 3 — the full gate.") +7. **Sorting is code-unit string comparison** (`Array.prototype.sort` default / `<` on strings), + never `localeCompare` — locale-aware collation is environment-dependent and would break P3. +8. **The golden lives at `test/fixtures/checkout-v1/expected-graph.json`** — the committed + correctness oracle of `07` §6 ② (legitimate because it is in `test/fixtures/`, never + `generated/`, which stays gitignored, L8). Its update flow is regenerate-and-review-diff; the + diff *is* the review. It is labeled distinctly from the determinism **self-check** (rebuild twice, + byte-compare) — two different claims, never conflated. + +## §2 — Work items + +### 1. Dependency + module skeleton (S) + +- `ts-morph` becomes the package's **first runtime dependency** (pinned minor; tsup externalizes + dependencies by default — verify `dist/` builds and the CLI still boots). +- New area **`src/extract/`** (the producer, beside `src/graph/` the schema and `src/validate/` the + checks): `discover.ts` · `reify.ts` · `derive.ts` · `serialize.ts` · `index.ts`. +- Public entry: `extract({ root }): ExtractionResult` where the result carries `graph` + (`GraphSchema`), `report` (the existing `ValidationReport`/`Finding` contracts — one diagnostic + currency, no parallel report shape), and `model` (`AuthoredModel` — the MD-14 Slice-1 bridge that + feeds the existing floor checks; it dissolves at the Slice-3 re-key). Exported from the barrel: + the extractor is the package's headline capability. + +### 2. Discovery (S) + +- File set: `/**/*.sdp.ts` minus `node_modules/` / `dist/` / `generated/` (reads `*.sdp.ts` + from day one — the `.sdp.ts` extension, MD-15). The file list is sorted for stable diagnostics + (output ordering never depends on filesystem enumeration anyway — item 5 owns ordering). +- `ts-morph` `Project` with `skipAddingFilesFromTsConfig`; **no type checker, no tsconfig + dependence** — reification is pure AST reading. Spec files are reified standalone; the extractor + never follows imports (static reification without execution — one validation path, MD-14). + +### 3. Static reification — the two tiers (L) + +The heart of the slice. A spec file is "a JSON file that TypeScript happens to validate" (P5). + +**Recognized top-level statements** — anything else in a `*.sdp.ts` file yields a file-level +`extract/unrecognized-statement` warning (loud without inventing a hard-error class the base does +not define; the `sdp/spec-static` lint named in `04` §1 remains future work): + +- import declarations whose module specifier is `@libar-dev/software-delivery-protocol` (the + recognized builders are matched **by import binding**, so aliasing survives; identifiers bound to + any *other* import are non-static wherever they appear); +- (exported) `const` declarations whose initializer is a `spec({ … })` or `pack({ … })` call bound + to such an import. + +**Value grammar:** string literals (and no-substitution template literals), number/boolean literals, +array literals, fresh object literals, the id-builder unwraps (`specId` / `packId` / `ref` around a +string literal), and — inside `relations[]` — exactly the six relation builders (`refines` · +`dependsOn` · `constrainedBy` · `decidedBy` · `verifies` · `supersedes`). `as const` and parentheses +are transparent. Everything else is non-static. + +**The two tiers (`04` §1 / `03` §2), per finding id:** + +| Finding | Tier | Severity | Behavior | +|---|---|---|---| +| `extract/non-static-envelope` | envelope: `id` · `kind` · `altitude` · `readiness` · any relation entry/target; pack `id` · every `specs[]` / `modelRefs[]` entry | **error** | spec/pack not extracted; build fails | +| `extract/invalid-id` | any id/target failing `parseId` grammar | **error** | same — the graph is never keyed on a malformed id | +| `extract/duplicate-id` | the same id reified from two sites (L2 — ambiguity is loud) | **error** | both sites reported; build fails | +| `extract/non-static-section` | a non-static property inside an optional section (or `title`/`framing`) | **warning** | that one property dropped; the rest of the spec survives (graceful partial extraction, L3) | +| `extract/unrecognized-statement` | a statement outside the recognized set | **warning** | statement ignored, loudly | + +A `relations[]` entry that is **not** one of the six builders (e.g. a raw object literal smuggling a +`satisfies` edge) is an envelope **error** — the extraction-layer twin of authoring-shape honesty, +activating the reserved fixture name `invalid-hand-authored-satisfies-edge`. + +Reification constructs **plain `Spec`/`Pack`-shaped objects directly from the AST** — it never +calls the runtime builders (evaluation is the phantom-value trap MD-14 closes). GWT entries and +`IntentOpenQuestion` objects reify per the typed section shapes. + +### 4. Graph derivation (M) + +- **Nodes:** one `PrimitiveNode` per spec, one `PackNode` per pack — both `claim: "declared"` + (authored). No `Anchor`/`CodeNode` this slice. Schema growth per §1.2; `schemaVersion` → `0.2.0` + (additive, L9; `06` §6's minimal versioning). +- **Edges:** one edge per authored relation (`from` = the carrying spec, `claim: "declared"`), and + one derived `belongsTo` per manifest entry (spec → pack) carrying `claim: "declared"` — a + deterministic re-expression of the declared manifest inherits its source's claim; there is no 4th + claim (`03` §3). +- **Dangling targets are emitted, not dropped:** an edge whose `to` resolves to no node still + serializes — the unresolved id itself is the sentinel — and surfaces as a validation finding (L3); + the full referential-integrity gate is Slice 3 (the tracer-bullet test's `validateAuthoredModel` + covers it meanwhile). +- **Delivery facts stay empty and are omitted when empty.** No anchored edges exist before Slice 2, + and a *declared* `verifies` edge confers nothing — `has-verifier` requires an **enabled verifier** + (a resolvable test binding; binding, never liveness — MD-7; `02` Verifier semantics). The example's + graph at Slice 1 honestly shows zero delivery facts. + +### 5. Deterministic serialization (S) + +All output bytes are owned by `serialize.ts`, so no `ts-morph` upgrade can change them silently: + +- nodes sorted by `id`; edges by `(from, type, to)` (P3; code-unit compare, §1.7); +- one canonical key order per node/edge shape, pinned in the serializer; 2-space indent; LF; final + newline; UTF-8 without BOM; +- no wall-clock timestamps, no run hashes, no absolute paths (JS-C3); +- report findings sorted by `(file, position, validatorId)` — the report is deterministic too; +- output path: `/generated/graph.json` (`generated/` is already gitignored, L8). + +### 6. CLI: `sdp build` + `--check-clean` (S) + +- `sdp build [root]` (root defaults to cwd): discover → reify → derive → emit; prints a summary + (specs/packs/nodes/edges/warnings) and every finding; exit 0 clean, exit 1 + **no `graph.json`** + on hard errors (§1.5). Stays synchronous (`ts-morph` is synchronous; `runSdpCli`'s signature + holds). +- `sdp build --check-clean`: **two independent in-process extractions, byte-compared** — a + self-comparison, never a diff against a committed artifact (`generated/` is gitignored — `03` §2, + `07` §6 ②). Divergence exits 1. +- `validate` keeps its honest rejection (the gate is Slice 3). `test/cli.test.ts` updated: build + writes the file and exits 0; `--check-clean` passes; a hard-error corpus exits 1 and writes + nothing; help text updated. + +### 7. The example becomes the first real input (M) + +- **Tracer-bullet re-point:** `test/checkout-v1.test.ts` builds the model via + `extract({ root: "examples/checkout-v1" })` and asserts `validateAuthoredModel` still reports + **zero findings** — the extractor now feeds the floor checks (one validation path, MD-14), and the + import-evaluation path retires. +- **Delete `examples/checkout-v1/model.ts`** (the hand-assembly; the extractor discovers by glob). + Check `examples/bootstrap-alias.ts` and the examples tsconfig for references and re-point. The two + anchor constants stay where they are, typechecked in place — Slice 2 extracts them; the test's + anchor-presence assertions move to direct imports or wait for Slice 2. +- **Doc-comment update** on `src/validate/authored-model.ts` per §1.6: the extractor is the sole + in-repo producer; the public export retires with the Slice-3 graph-validator re-key. +- **The golden correctness oracle:** commit `test/fixtures/checkout-v1/expected-graph.json`; a test + extracts the example and byte-compares (did the extractor produce the *right* graph). Expected + content: 10 nodes (9 `Primitive` + 1 `Pack`), the declared edges from all authored relations, 9 + `belongsTo` edges, full sections, zero delivery facts. +- **No dropped sections** (`07` §6 (b)): assert the example's extraction report carries zero + `extract/non-static-section` warnings — the fixture survives static extraction whole. + +### 8. Extraction fixture corpus + activations (M) + +- New corpus root `test/fixtures/extract//` holding tiny on-disk `.sdp.ts` corpora (the + extractor reads files, not in-memory objects). Excluded from the typecheck tsconfigs — they + exercise the extractor, not `tsc` (and `tsconfig.json`'s `test/**/*.ts` include would otherwise + sweep them); vitest never collects them (the `.sdp.ts` extension, MD-15, doing its job). +- **Activate three reserved extractor-era fixture names** (recorded in + `test/fixtures/authored-model.fixtures.ts`'s doc comment): `invalid-non-static-id` (envelope hard + error; a sibling valid spec in the same corpus still extracts — L3), `invalid-non-static-section` + (property dropped + warning, spec survives), `invalid-hand-authored-satisfies-edge` (raw + `relations[]` entry → envelope error). Each pins one finding id, should-fail/should-pass style + (`05` §5). Move them out of the "awaiting" list in that doc comment; `invalid-ready-with-unresolved-dependency` + and `invalid-ready-with-target-below-defined` stay reserved for Slice 3 (the graph-shaped `ready` + clauses ride the gate). +- Plus: a duplicate-id corpus (two files, one id — L2), a dangling-relation corpus (edge emitted, + finding raised), and the **determinism self-check** test labeled as such (extract twice → + byte-identical; plus an end-to-end delete-`generated/`-and-rebuild through the CLI path). + +## §3 — Verification (the done gate) + +1. `npm run check` green end-to-end (the temporal guard sweeps all new files by default — + subtractive, never additive). +2. The golden test (correctness oracle) and the determinism self-check both pass and are **named + distinctly** in test titles (`07` §6 ②). +3. The no-dropped-sections assertion passes against the untouched example (tracer-bullet + discipline: any reification gap found is fixed in the extractor; the example changes only if a + genuine authoring bug surfaces — then flagged, not silently edited). +4. `sdp build examples/checkout-v1` from a clean checkout writes `graph.json`; delete `generated/`, + rebuild, byte-identical; `--check-clean` exits 0. +5. The tracer-bullet test passes on extractor output with zero findings; `examples/checkout-v1/model.ts` + is gone; `grep -rn "model.ts" examples test` shows no dangling references. +6. Write this plan's done-record header at session end (Status: executed; **Next: Slice 2 — generic + anchors (`codeAnchor`, MD-8; H10's api anchor) + implementation/test binding → `satisfies` and + anchored `verifies` edges**) — the only place carrying "what next." + +## §4 — Explicit non-goals (deferred by decision, not omission) + +- **Anchors and the anchored layer** — `codeAnchor` (the generic `codeAnchor`, MD-8), anchor + extraction, `satisfies`/anchored-`verifies` edges, `Anchor`/`CodeNode` nodes, delivery-fact + computation, the example's missing api anchor (H10) → **Slice 2**. +- **The graph-validator gate** — re-keying the conformance + honesty checks to the graph, `sdp + validate`, the public demotion of `AuthoredModel` + the pre-graph validators, the graph-shaped + `ready` clauses and their two reserved fixtures, pack-coherence `modelRefs` kind check (F4), + orphan/`gap` surfacing, CI gating on `sdp validate` → **Slice 3**. +- **The inferred layer** — even "minimal advisory" (basic test discovery) rides Slice 2's anchor + work; Slice 1 emits zero `inferred` claims. +- **The reader / agent surface / Design Review / derived-readiness banner** → Slice 4; **CLI polish, + `explain`/`search`, error-message work, the documented example** → Slice 5. +- **A persisted `report.json`** — the report prints to the CLI and returns in-memory; whether the + Slice-3 gate persists it is decided there. +- **The decision-spec fold** (DECISIONS.md durables → `kind:"decision"` specs) — explicitly *after* + Slice 1; it becomes the extractor's second corpus. +- **The generated `spec-ids` union** (L8 optional convenience) and the **`sdp/spec-static` lint** — + later, optional. +- **Self-hosting** this repo's own model — a later milestone, never a Slice-1 claim (`00` §3). + +## §5 — Risks + +- **Reification grammar vs the real example.** The example uses `specId(…)` wraps, structured GWT, + open-question objects — the grammar above was written against it, but the tracer bullet decides: + any gap is an extractor fix, never an example simplification. +- **Golden brittleness is the feature.** Every meaningful example edit changes the golden; the + regenerate-and-review-diff flow makes that the review. The risk is blind regeneration — the test + failure message should say "review the diff, then update," not "run X to fix." +- **`ts-morph` is a heavy first dependency.** Pinned version; serializer owns all output bytes + (§2.5), so upgrades can shift only what we *read*, caught by the golden, never silent output + drift. CLI cold-start cost accepted at MVP scale. +- **Schema `0.2.0` ripples** through `test/graph-schema.test.ts` / `graph-schema.typecheck.ts` — + mechanical, covered by the check gate. +- **The temporal guard vs committed fixtures:** the golden contains no ISO dates today; if a future + corpus legitimately needs one, narrow the guard's date pattern in the same commit (plan 05's + recorded path), never widen the genre exemptions. +- **`model.ts` deletion ripple** — `bootstrap-alias.ts`, the examples tsconfig, and any test + importing the hand-assembled model are re-pointed in the same commit (verification §3.5). diff --git a/plans/07-slice2-anchors.md b/plans/07-slice2-anchors.md new file mode 100644 index 0000000..6669e61 --- /dev/null +++ b/plans/07-slice2-anchors.md @@ -0,0 +1,294 @@ +# Plan 07 — Slice 2: generic anchors and the anchored layer + +> **Status: ✅ EXECUTED 2026-06-10** — all eight work items landed on `feature/anchors`; +> `npm run check` green end-to-end. The example extracts its three anchors (impl + api + test) +> with **zero findings**; the graph is exactly the predicted shape — 13 nodes (2 `CodeNode` + +> 1 `Anchor` added), 25 edges (2 anchored `satisfies` + 1 anchored `verifies` added), and the +> first derived delivery facts: `spec:orders.create-order` carries +> `["implemented", "has-verifier"]`, `spec:orders.create-order.valid-cart` carries +> `["has-verifier"]`, and the invalid-cart example honestly carries nothing (declared `verifies` +> without a test binding confers nothing — MD-7). Through the built CLI: `--check-clean` passes, +> delete-`generated/`-and-rebuild is byte-identical, and the emitted bytes equal the committed +> golden. The repo root still builds cleanly as a default root — the import-binding rule (not the +> text pre-filter) is what keeps this repo's own protocol-mentioning files (`vitest.config.ts`, +> `src/extract/reify.ts`, relative-import tests) out of the anchored layer, exactly as §5 +> predicted. +> +> **Execution deviations, all minor:** (1) the `misplaced-anchor` corpus pins *both* +> `extract/misplaced-authoring` shapes in one corpus (an anchor call in a function body **and** a +> `spec(…)` in a source file) — one finding id, two warnings asserted; (2) `ids.test.ts` gained a +> dedicated MD-8 test (the one `codeAnchorId` brands all three implementation-flavored +> namespaces) beyond the §2.1 rename ripple; (3) `requireNamespace` in `ids.ts` generalized to a +> namespace *list* (the multi-namespace error message now reads `expected one of the namespaces +> "impl" · "api" · "component"`) — the §2.1 item's natural shape, recorded for the message-text +> change it implies. +> +> **Post-execution adversarial pass (Codex, 2026-06-10) — three valid findings, all landed as +> drift repair forced by the base (L2 · MD-10 · `04` §2), no DECISIONS entries:** +> (1) *silent top-level property loss* — `reifySpecCall`/`reifyPackCall` accepted any unknown +> top-level property while `deriveGraph` serialized only the eight sections, so a typo +> (`behaviour`) or a hand-authored graph-shaped field could vanish from the graph with zero +> findings; the extractor now hard-errors on derived graph vocabulary at the top level +> (`extract/reserved-property` — delivery facts · `claim` · edge fields; the extraction-layer +> twin of authoring-shape honesty) and warns-and-drops anything else outside the authored shape +> (`extract/unrecognized-property`), pinned by the `invalid-reserved-property` and +> `unrecognized-property` corpora. (2) *refs smuggled through section content* — the static value +> grammar unwrapped id builders in any value position (a path only section content ever reached), +> so `ref(…)` in a section survived as plain prose against MD-10; id builders now unwrap in id +> slots only, and a section ref drops loudly (the `ref-in-section-content` corpus). (3) *the +> tracer-bullet verifier was an anchor-only file* — `04` §2 puts the runner test beside the +> binding anchor, so `create-order.valid-cart.test.ts` gained its executable half (the example's +> GWT: stable id · cart-math total · original lines) and the example's tests joined the vitest +> include; the golden's one-line diff is the anchor's binding location moving with the file. +> A validation re-review pressed one boundary on fix 2: a *raw id-shaped string* in section +> content. Resolved as the documented boundary, not a guard extension (the reviewer's own +> lighter remedy): the MD-10 guard covers the typed affordance (`ref(…)`) — prose that happens +> to name a spec id is content by definition (no claim, no edge, no validation), and closing it +> would mean policing prose, which checks never do (MD-1 guardrail 1). The boundary is stated on +> the guard (`src/extract/reify.ts`) and pinned by the `id-shaped-string-content` corpus; whether +> an *informative* id-shaped-prose surfacing (a `gap`-style signal, never a gate) earns its way +> in is decided at Slice 3 with the check families. +> +> **Next session: Slice 3 — the graph-validator gate**: re-key the conformance + honesty checks +> to the graph (one validation path, MD-14 completes), wire `sdp validate`, demote +> `AuthoredModel` + the pre-graph validators, land the graph-shaped `ready` clauses and their two +> reserved fixtures (`invalid-ready-with-unresolved-dependency` · +> `invalid-ready-with-target-below-defined`), the `verifies`-linkage check, pack coherence (F4), +> orphan/`gap` surfacing, and CI gating on `sdp validate`. +> +> **Spec anchors:** `02` §2 (delivery facts; verifier semantics — direct, per-spec, not transitive) · +> `02` §5 (the `impl`/`api`/`test`/`component` namespaces) · `03` §1 (what the extractor reads; the +> edge contract — `satisfies` CodeNode→Primitive, `verifies` Anchor(test)→Primitive, both anchored) · +> `04` §2 (anchors assert a binding, never intent — R1; the binding-only `specTest`, R3; the generic +> `codeAnchor`, MD-8) · `05` §2 (one validation path, MD-14 — the model bridge persists until Slice 3) +> · `07` §6 (c) ("extracts one api anchor" is the H10 gap) · JTBD theme B (bind code to intent), +> JS-C2 (trust every claim). + +## Context + +Slice 1 shipped the extractor for the **declared layer**: spec files and pack manifests reify +statically into the one graph (10 nodes, 22 edges on the example), the golden oracle and the +determinism self-check pin it, and `extract(…).model` feeds the pre-graph validators (the MD-14 +bridge). Anchors exist only as DSL builders (`anchorImplementation` / `specTest`) typechecked in +place in the example; `model.anchors` is hard-coded empty; the graph carries zero `anchored` claims +and zero delivery facts. + +Slice 2 builds the **anchored layer**: the generic `codeAnchor` (MD-8), anchor extraction from +source files, `Anchor`/`CodeNode` graph nodes, anchored `satisfies`/`verifies` edges, and the first +**derived delivery facts** (`implemented` / `has-verifier`). The example gains its missing api +anchor (H10). The graph-validator gate stays Slice 3; the reader stays Slice 4. + +## §1 — Decisions this plan pins (Representation, not DECISIONS.md entries) + +1. **`codeAnchor` replaces `anchorImplementation` outright** (the generic `codeAnchor`, MD-8 — this + is its fold). Zero adopters and zero back-compat: the narrower builder, `implAnchorId`, and the + `ImplAnchorId` brand are **removed**, not deprecated. New: a `CodeAnchorId` brand and a + `codeAnchorId(…)` builder accepting the three implementation-flavored code namespaces + (`impl` / `api` / `component`). `specTest` / `testAnchorId` stay as they are: the test anchor is + the *verifying* binding (it emits `verifies`, not `satisfies`) — a different binding direction, + not the per-namespace sibling MD-8 rejected. The MD-8 rationale lands as the `codeAnchor` + doc-comment (the registry row already names that destination); the registry row is annotated + folded. +2. **The extracted anchor surface is the anchor-constant form in non-spec source files.** Discovery + walks `/**/*.ts`+`*.tsx` minus `*.sdp.ts` (the spec surface), `*.d.ts`, and the same + excluded directories as spec discovery. Recognition is **by import binding** from the protocol + module, exactly as in spec files; a cheap pre-parse text filter (the file must contain the + protocol module specifier) keeps `sdp build` from AST-parsing every source file in a real repo — + sound, because the binding rule requires that literal in an import declaration. The decorator and + JSDoc forms (`04` §2) stay unextracted Representations. +3. **Source files are real product code — no recognized-statement sweep.** Spec files warn on every + foreign statement; source files are the opposite: the extractor only *looks for* top-level + `const` declarations initialized with `codeAnchor(…)`/`specTest(…)` calls bound to the protocol + import. One new loud case (L2 — a binding the author believes exists must never silently fall out + of the graph): a protocol *authoring* call outside its recognized surface — an anchor builder not + in top-level-const position, or a `spec(…)`/`pack(…)` call in a non-`.sdp.ts` file — yields a new + warning finding **`extract/misplaced-authoring`** and is not extracted. +4. **Anchor reification reuses the Slice-1 two-tier finding currency.** An anchor is almost all + envelope: `id` and the `satisfies`/`verifies` target are binding identity — non-static or + grammar-failing values are hard errors (`extract/non-static-envelope` / `extract/invalid-id`), + the anchor is not extracted, the build fails. `label` is degradable detail: dropped with the + content-tier warning (`extract/non-static-section` — the finding id names the *tier*, not the + artifact; its doc-comment now says so). Duplicate ids — across specs, packs, *and* anchors, one + id space keys the graph — follow the Slice-1 rule: every site reported + (`extract/duplicate-id`), the model records all, the graph excludes all. +5. **Graph shape per the `03` §1 sample and the edge contract.** A `codeAnchor` derives a + **`CodeNode`** `{ id, claim: "anchored", label?, file, line }` plus a `satisfies` edge + (anchor → spec, `claim: "anchored"`). A `specTest` derives an **`Anchor`** node (same fields) + plus a `verifies` edge (anchor → spec, `claim: "anchored"`). Schema grows additively (L9): + `AnchorNode` += `label?` · `file` · `line`; `CodeNode` += `label?` (its `line` stays optional in + the schema, always emitted for extracted anchors); `schemaVersion` → `0.3.0`. **Line numbers + enter the graph for binding nodes only** — the Slice-1 revisit lands as decided there: a binding + node's `line` *is* the binding location (what a Design Review links to, R2); `Primitive`/`Pack` + nodes stay line-free so the golden stays robust to spec-file editing. +6. **Delivery facts are computed in derivation, exactly per `02` §2.** `implemented(S)` = ≥1 + `satisfies` edge resolves to S (S's node exists). `has-verifier(S)` = an **anchored** `verifies` + edge resolves to S directly (a test anchored to the spec), **or** a **declared** `verifies` edge + from an *enabled* example resolves to S — enabled = an `example`-kind Primitive that is itself + the target of a resolving anchored `verifies` edge. No transitivity up `refines`; `observed` is + never computed. Facts serialize in ladder order (`implemented`, `has-verifier`). A declared + `verifies` from a non-`example` spec confers nothing (the base names the example/scenario as the + enabled-verifier shape); *surfacing* unenabled verifiers is the Slice-3 `verifies`-linkage + check's job, not a Slice-2 finding. +7. **Dangling anchor targets are emitted, not dropped** — the unresolved id is the sentinel, same + as Slice-1 declared relations. Resolution gates the delivery fact (a dangling `satisfies` + confers no `implemented`), and `validateDanglingReferences` flags the miss through the model + bridge until the Slice-3 gate. +8. **Still zero `inferred` claims — a named deviation from plan 06 §4's aside** ("even 'minimal + advisory' rides Slice 2's anchor work"). No consumer reads inferred edges before the Slice-4 + reader/impact surface, and emitting unread machine guesses would pin speculative bytes in the + golden. The minimal advisory inferred set is decided when its consumer lands (Slice 4), which is + also when "minimal" can be measured against a real query. + +## §2 — Work items + +### 1. The `codeAnchor` DSL (S) + +- `src/ids.ts`: `CodeAnchorId` brand + `codeAnchorId(…)` over `impl`/`api`/`component` (the + namespace-set check generalizes `requireNamespace`); `implAnchorId`/`ImplAnchorId` deleted. +- `src/model/anchors.ts`: `CodeAnchor { id, label?, satisfies }` replaces `ImplementationAnchor`; + `codeAnchor(…)` replaces `anchorImplementation`; the doc-comment carries the MD-8 rationale + (generic by definition — the binding is the thing; per-namespace siblings rejected as surface + bloat). `Anchor = CodeAnchor | SpecTestAnchor`. +- Ripple: `test/builders.test.ts`, `test/builders.typecheck.ts`, `test/readiness.typecheck.ts`, + `test/validators.test.ts`, `test/ids.test.ts`/`ids.typecheck.ts` re-point to the new names. +- `docs/concept/04` §2 reconciles to the landed state (the "until then the DSL ships the narrower + `anchorImplementation`" sentence retires; the anchor-constant sample shows the real single-target + signature — the R1/R3 pattern: the code is the conformant truth). `DECISIONS.md` MD-8 row + annotated folded. + +### 2. Discovery of anchor-candidate source files (S) + +- `src/extract/discover.ts`: the one walk now returns spec files **and** anchor-candidate source + files (`*.ts`/`*.tsx`, minus `*.sdp.ts`, `*.d.ts`, excluded directories), both sorted code-unit. +- The pre-parse filter (§1.2) lives at the extract step: read the text once, skip AST work when the + protocol module specifier is absent. + +### 3. Anchor reification (M) + +- New `src/extract/anchors.ts`, reusing `reify.ts`'s grammar internals (exported module-to-module, + not via the barrel): import-binding collection, transparent unwraps, static string/id + reification. +- `ReifiedAnchor { data, id, flavor: "code" | "test", file, line }`; namespace checks per slot + (`codeAnchor` id ∈ impl/api/component · `specTest` id ∈ test · targets ∈ spec); `label` + degradable (§1.4). +- The misplaced-authoring scan (§1.3): any descendant call expression bound to a protocol authoring + builder (`spec` · `pack` · `codeAnchor` · `specTest`) that is not a recognized top-level + const initializer (anchors in source files; spec/pack never) warns `extract/misplaced-authoring`. + The same scan applies to `spec(…)`/`pack(…)` calls in source files. + +### 4. Graph derivation + delivery facts (M) + +- `derive.ts` takes the reified anchors: one `CodeNode` per code anchor, one `Anchor` node per test + anchor, one anchored edge each (§1.5); then the delivery-fact computation (§1.6) decorates + `PrimitiveNode.deliveryFacts`. +- `serialize.ts`: canonical key order for the two node shapes — `id`, `nodeType`, `claim`, + `label?`, `file`, `line` — mirroring `title`-before-`file` on `Primitive`. +- `src/graph/schema.ts`: `0.3.0` + the §1.5 field growth; `test/graph-schema.test.ts` / + `graph-schema.typecheck.ts` ripple. + +### 5. Extractor wiring (S) + +- `extract()`: reify anchors after specs/packs; duplicate detection spans all three carriers + (§1.4); `model.anchors` carries the reified anchors (the MD-14 bridge, honestly populated); + `deriveGraph(specs, packs, anchors)`. +- `src/extract/index.ts` doc-comments updated: the anchored layer is in; the inferred layer is the + named Slice-4 deferral (§1.8). + +### 6. CLI (S) + +- `sdp build` summary gains the anchor count (`… specs · … packs · … anchors → … nodes · … edges`); + help text says anchors are extracted from source files under the root. `test/cli.test.ts` + expectations updated (the example: 9 specs · 1 pack · 3 anchors → 13 nodes · 25 edges). + +### 7. The example completes its anchored layer (M) + +- `create-order.use-case.ts` re-points to `codeAnchor`/`codeAnchorId`. +- **H10:** new `examples/checkout-v1/src/orders/create-order.route.ts` — a small route handler + delegating to the use case, anchored `api:orders.post` → `satisfies` → + `spec:orders.create-order` (`04` §5's repo shape). +- The test anchor file stays as is (`specTest` is unchanged). +- **Golden regenerated and review-diffed:** +3 nodes (2 `CodeNode` + 1 `Anchor`), +3 edges + (2 anchored `satisfies` + 1 anchored `verifies`), `spec:orders.create-order` gains + `["implemented", "has-verifier"]`, `spec:orders.create-order.valid-cart` gains + `["has-verifier"]`, `schemaVersion` `0.3.0`. The invalid-cart example honestly stays fact-free + (it has no test anchor — not an enabled verifier). +- `test/checkout-v1.test.ts` re-points: anchors arrive via extraction (the direct-import assertions + retire with the "until Slice 2" test); the zero-delivery-facts assertion inverts to pin the + Slice-2 facts above; `validateAuthoredModel` still reports zero findings on the extracted model. + +### 8. Extraction corpora + activations (M) + +- `test/helpers/extract-corpus.ts` generalizes: materialize any `*.txt`-defused file (corpora now + carry `.ts.txt` source files beside `.sdp.ts.txt` spec files). +- New corpora under `test/fixtures/extract/`, one pinned outcome each (`05` §5 style): + - `anchored-binding` (pass): parent spec + verifying example + impl anchor + test anchor → the + full ladder: anchored edges present; `implemented` + `has-verifier` on the parent, + `has-verifier` on the example. + - `unenabled-verifier` (pass): the example declares `verifies` but has no test anchor → **no** + `has-verifier` anywhere (binding, never liveness — MD-7's gate is structural enablement). + - `invalid-non-static-anchor` (fail): a non-static anchor target → `extract/non-static-envelope`; + a sibling static anchor in the same corpus still extracts (L3). + - `invalid-anchor-namespace` (fail): `codeAnchor` carrying a `test:` id → `extract/invalid-id`. + - `duplicate-anchor-id` (fail): the same anchor id from two files → both sites reported, neither + in the graph, the model records both. + - `dangling-anchor` (pass extraction): `satisfies` → a missing spec — edge emitted, **no** + `implemented`, `validateDanglingReferences` flags it. + - `misplaced-anchor` (warn): an anchor call inside a function body → `extract/misplaced-authoring`, + not extracted. + - `non-static-anchor-label` (warn): the label drops, the anchor survives whole. + +## §3 — Verification (the done gate) + +1. `npm run check` green end-to-end (the temporal guard sweeps the new files by default). +2. The golden test and the determinism self-check pass, still named distinctly; the regenerated + golden's diff is reviewed as part of the commit (the diff is the review). +3. The tracer bullet holds: the untouched example specs + the two existing anchors + the new route + anchor extract with **zero findings**, and `validateAuthoredModel` on the extracted model stays + clean. +4. The repo root still builds cleanly as a default root (the Slice-1 regression test now also + sweeps source files for anchors — the example's anchors resolve against the example's specs from + the same root). +5. Every new corpus pins its finding id should-fail/should-pass style; the Slice-3 reserved + fixtures stay reserved. +6. `sdp build examples/checkout-v1` writes the 13-node graph; delete `generated/`, rebuild, + byte-identical; `--check-clean` exits 0. +7. Done-record header written at session end (Status: executed; Next: Slice 3 — the graph-validator + gate: re-key conformance + honesty checks to the graph, `sdp validate`, the `AuthoredModel` + demotion, the two reserved `ready`-clause fixtures, CI gating). + +## §4 — Explicit non-goals (deferred by decision, not omission) + +- **The graph-validator gate** — re-keying checks to the graph, `sdp validate`, `AuthoredModel`'s + public demotion, the graph-shaped `ready` clauses (`all-relations-resolve` · + `depends-on-and-refines-targets-are-defined` · `anchors-resolve`), the `verifies`-linkage check + surfacing unenabled verifiers, pack coherence (F4), orphan/`gap` surfacing → **Slice 3**. +- **The inferred layer** — zero `inferred` claims this slice (§1.8) → **Slice 4** with its consumer. +- **Decorator / JSDoc anchor forms** — unextracted Representations (`04` §2); the anchor-constant + form is the MVP surface. +- **Structural bindings beyond `satisfies`/`verifies`** — `component`, `implements`, + `handles`/`emits` (`04` §2 names them; aspirational) — the anchor stays `{ id, label?, target }`. +- **Multi-target anchors** — one binding per anchor; `04` §2's decorator sketch shows an array, but + the landed single-target shape is the conformant truth (R1/R3 pattern) and two bindings are two + anchors. +- **The reader / agent surface / Design Review / derived-readiness banner** → Slice 4; **CLI + polish** → Slice 5. +- **The decision-spec fold** (DECISIONS durables → `kind:"decision"` specs) — after this slice as + before; it becomes the extractor's next corpus. + +## §5 — Risks + +- **Repo-root sweep collisions.** Anchor discovery now reads every `.ts` under a build root; files + in *this* repo that merely mention the protocol specifier string (`vitest.config.ts`, + `src/extract/reify.ts`) pass the text filter but bind nothing — the import-binding rule is the + real gate. The Slice-1 repo-root regression test is the canary; it must stay green without + narrowing discovery. +- **Golden brittleness grows a notch** — binding nodes carry `line`, so editing the example's + source files above an anchor moves the golden. Accepted in §1.5 (the line is the binding + location); the regenerate-and-review-diff flow is unchanged. +- **Two-pass grammar drift.** Anchor reification reuses the spec grammar's internals rather than + duplicating them — one static-value grammar, two surfaces. Any divergence (e.g. id-unwrap sets) + is explicit in `anchors.ts`, not a copy. +- **`ts-morph` parse cost on big roots.** The text pre-filter bounds it; at MVP scale (<~50 specs) + full rebuild stays comfortable (`00`/`05`/`07`). +- **Example route handler realism.** The route file must stay a plausible thin handler (tracer + discipline) without dragging a framework in — a plain function named like a route, not Fastify. diff --git a/plans/08-slice3-graph-validator-gate.md b/plans/08-slice3-graph-validator-gate.md new file mode 100644 index 0000000..fbfc4a7 --- /dev/null +++ b/plans/08-slice3-graph-validator-gate.md @@ -0,0 +1,311 @@ +# Plan 08 — Slice 3: the graph-validator gate + +> **Status: ✅ EXECUTED 2026-06-10** — all nine work items landed on `feature/anchors`; +> `npm run check` green end-to-end, now closing with the CI gate itself +> (`check:example` = the built CLI running `validate examples/checkout-v1 --check-clean`). +> One validation path is complete (MD-14 executed): `validateGraph` is the sole validation seam, +> the nine `05` §2 validators run over the one graph, the three `ready` floor clauses are active +> predicates, and `AuthoredModel` is deleted (the extractor now returns `counts` beside the graph +> and the report). The example validates to **0 errors and exactly 1 warning** — the invalid-cart +> example's unenabled verifier, the surfaced absence §1.6 planned — and the **golden is +> byte-identical to the Slice-2 bytes** (no schema growth; findings stay CLI output, never graph +> bytes). The two reserved fixtures are active corpora (the target-below-defined corpus reproduces +> the glossary's worked dialogue: create-order not ready because `spec:payments.authorize-payment` +> is still scoped); six more corpora pin the new validators; synthetic-graph tests pin the +> backstops (`duplicate-ids` · `claim-separation` · `anchors-resolve` · edge-`from` resolution · +> the unique-or-silent did-you-mean). The §3.5 gate split holds: the dangling-relation corpus +> builds clean (exit 0, sentinel edge emitted) and fails `sdp validate` (exit 1, artifact kept — +> the graph is the faithful projection; the errors describe the repo). +> +> **Execution deviations, all minor:** (1) `valid-minimal-idea-spec` gained a pack carrier — under +> orphan surfacing a lone spec is honestly an orphan, so the clean-minimal pin is now "one spec in +> one pack" and the lone-spec shape moved to the `orphan-spec` corpus; (2) the old composed-model +> validators test had `modelRefs` pointing at a behavior-kind spec — the new pack-coherence check +> (F4) caught the pre-F4 sloppiness, and the test was re-authored around a real `model`-kind spec; +> (3) the gaps validator's should-pass pin rides a synthetic node carrying +> `deliveryFacts: ["has-verifier"]` (silencing the gap) rather than a full anchored ladder — the +> ladder itself is already pinned by `anchored-binding`. +> +> **Post-execution adversarial pass (Codex, 2026-06-10) — two valid findings, both landed as +> drift repair forced by the base (delivery facts are derived, never authored — `02` §2; fail +> closed on the public seam), no DECISIONS entries:** (1) *the graph seam trusted stated +> `deliveryFacts`* — `honesty/gaps` read the node's array, so a foreign producer could state a +> `has-verifier` no binding earns and silence the gap; the derivation rule moved to the shared +> `src/graph/delivery-facts.ts` (one derivation path — the extractor and the checks call the same +> function, now hardened to resolving edge-contract rows: identical on extractor output by +> construction, fail-closed for any other producer), the new **`honesty/delivery-facts`** check +> (`05` §2 check 6 — the validators are now ten, and the `05` §2 honesty/informative items +> renumbered) compares stated facts against recomputation (unknown names · unearned facts +> including `observed` · omissions), and `honesty/gaps` reads the recomputed facts, so a faked +> fact never silences it. (2) *unratified descriptors crashed or silently skipped the floor* — a +> foreign `specKind` threw in the evidence-table dereference and an unknown `readiness` evaluated +> no clauses silently; claim-separation now checks the three descriptors (`specKind` · `altitude` +> · `readiness`) against their ratified values (conformance errors), and `evaluateReadinessFloor` +> is total — over an unratified kind or readiness it evaluates no clauses, the conformance error +> owning the finding. Both pinned by synthetic-graph tests; the gaps should-pass pin (execution +> deviation 3 above) re-authored onto a real resolving test binding. A second pass (same +> session, post-commit) pressed one more row of the same seam: the `03` §1 **kind-typed +> endpoints** were stated but unenforced (§1.2 scoped endpoint checks to `nodeType`) — landed as +> claim-separation conformance errors: `constrainedBy` → a `rule`/`constraint`-kind spec (per +> `02` §6's "a rule / NFR / policy spec" — `constraint`-only would contradict MD-16's +> contemplated rule-kind targets), `decidedBy` → a `decision`-kind spec, `supersedes` only +> between `decision`-kind specs; evaluated only on resolving, ratified-kind endpoints (dangling +> is referential integrity's finding, unratified the descriptor check's), and the +> declared-`verifies`-from-an-example row deliberately stays the informative `verifies`-linkage +> warning (§1.2's pinned severity). Pinned by DSL-fixture should-fail/should-pass tests. +> +> **Next session: Slice 4 — the agent surface**: the `reader` (thin typed loader; entry adapters + +> file-level impact with `coverage-unknown`), the Design Review / one generated read-only view +> (fully derived; renders binding language for `implemented`, `07` §6 ④), the derived-readiness +> banner (`07` §6 ③ — the floor evaluator already names the failing clause), and the minimal +> advisory `inferred` set, decided with its consumer. +> +> **Spec anchors:** `05` §1 (the two check families; an error fails the build, a `gap` informs) · +> `05` §2 (the MVP graph validators 1–8; one validation path, MD-14 — `sdp validate` is +> `sdp build` + checks) · `05` §3 (the readiness floor; the graph-shaped `ready` clauses; +> floor-table-as-truth, MD-13) · `05` §4 (pack coherence) · `05` §5 (validator self-testing — +> should-fail / should-pass fixtures per validator) · `03` §1 (the edge contract — the +> claim-separation and `verifies`-linkage rows) · `02` §4 (pack coherence reads the manifest's +> derived `belongsTo` + `modelRefs`) · `07` §1 Slice 3 + "what done looks like" (CI rejects a PR +> that breaks links or states readiness the spec has not earned) · JTBD theme D (keep it honest), +> JS-C2 (trust every claim). + +## Context + +Slice 2 finished the anchored layer: the example extracts 13 nodes · 25 edges with zero findings, +delivery facts derive honestly, and the golden + determinism checks pin the bytes. Validation, +however, still runs over the pre-graph `AuthoredModel` bridge (`extract(…).model`) — the Slice-1 +stand-in MD-14 explicitly scheduled for demotion — and the graph-shaped `ready` clauses sit inert +in the floor table (`evaluatedOver: "graph"`, no predicate). `sdp validate` is a stub that exits 1. + +Slice 3 completes the one validation path: the conformance + honesty checks re-key to **the one +graph** (the sole public validation seam), the `ready` clauses activate, `sdp validate` wires up as +`sdp build` + checks, the `AuthoredModel` seam retires wholesale, and CI gains the gate. No schema +growth: `schemaVersion` stays `0.3.0`, the golden stays byte-identical — findings are per-run +diagnosis (CLI output), never graph bytes. + +## §1 — Decisions this plan pins (Representation, not DECISIONS.md entries) + +1. **One public validation seam: `validateGraph(graph: GraphSchema): ValidationReport`.** The + individual graph validators are module-internal; the aggregate's `validatorId` is `graph` and — + the established aggregate rule — it carries no single `family` of its own (each finding does). + A `graphValidatorIds` map mirrors `extractFindingIds` (tests reference ids typo-safely). + Findings sort `(file ?? "", line ?? 0, validatorId, subjectId, relatedId)` — the extractor's + currency, one diagnostic shape. Findings carry the subject node's `file` where known. + +2. **The validator set** — `05` §2's eight checks, keyed one-to-one, each in exactly one family: + + | id | family | severity | checks | + |---|---|---|---| + | `conformance/referential-integrity` | conformance | error | every edge endpoint (`from` **and** `to`) resolves to a node; every `PackNode.modelRefs` entry resolves; a dangling reference carries a "did you mean …?" suggestion when a unique nearest id exists (edit distance ≤ 2) | + | `conformance/duplicate-ids` | conformance | error | no two graph nodes share an id (L2 — the graph backstop; the extractor's `extract/duplicate-id` stays the loud per-site error and excludes the carriers, so this fires only for a non-extractor producer) | + | `conformance/claim-separation` | conformance | error | node/edge typing valid and distinct per the `03` §1 edge contract: node claims by `nodeType` (`Primitive`/`Pack` → `declared`, `Anchor`/`CodeNode` → `anchored`); edge `(type, claim, endpoint nodeType)` rows (`satisfies` anchored CodeNode→Primitive · `belongsTo` declared Primitive→Pack · authored relations declared Primitive→Primitive · `verifies` declared Primitive→Primitive or anchored Anchor→Primitive); endpoint rows evaluate only where the endpoint resolves (dangling is referential integrity's) | + | `conformance/verifies-linkage` | conformance | warning | the bidirectional trace surfaced: (a) an `example`-kind spec with a declared `verifies` that is **not an enabled verifier** (no resolving anchored `verifies` targets it) — the spec↔test trace is incomplete; (b) a declared `verifies` from a non-`example` kind — confers nothing (MD-7 names the example/scenario as the enabled-verifier shape). The *missing-target* half of `05` §2 check 4 is owned by referential integrity (it is reference resolution) | + | `conformance/pack-coherence` | conformance | error | no duplicate member ids per pack (duplicate `belongsTo`); every **resolving** `modelRefs` target is a `model`-kind Primitive (F4 — the carried Phase-0 finding; resolution itself is referential integrity's) | + | `conformance/orphans` | conformance | warning | a Primitive with no incident edges at all — fallen out of the graph's connective tissue; informative, never a gate | + | `honesty/authoring-shape` | honesty | error | a delivery-fact name (`implemented` / `has-verifier` / `observed`) as a key anywhere inside a Primitive's **section content** — the layer split: extraction owns the top level (`extract/reserved-property` hard-errors before derivation), the graph check owns section interiors (the path `tsc` never sees: defused corpora, non-fresh literals, foreign producers) | + | `honesty/readiness-floor` | honesty | error | the floor over the graph, **all clauses active** (§1.3) | + | `honesty/gaps` | honesty | warning | the `05` §2 check-8 gap: a spec stating `ready` with no `has-verifier` delivery fact — a surfaced absence, informative only | + + Family assignment for the two informative checks (the base lists them outside the family + headings but the two-family law admits no third): orphans read as *structural connectivity* → + conformance; the verifier gap rides the readiness/delivery-fact seam → honesty. Severity + `warning` is the "informative" rendering — it never fails the build; per-config escalation + stays deferred with `--lenient`. + +3. **The floor re-key (MD-13 preserved).** Predicates become + `(node: PrimitiveNode, index: GraphIndex) => boolean`; the table stays the single source, the + evaluator stays one generic loop, the clause-id union stays derived. The + `GraphReadinessClause` marker shape retires — every clause now carries a predicate. Relations + read from declared authored-type edges (`belongsTo` is derived and never counts); promotion + neutrality walks `refines` edges into child nodes' sections. The three `ready` clauses land: + + - **`all-relations-resolve`** — every declared authored edge from the spec has a resolving + target. + - **`depends-on-and-refines-targets-are-defined`** — every *resolving* `dependsOn`/`refines` + target is a Primitive stating ≥ `defined` (an unresolved target is `all-relations-resolve`'s + failure — no double-fire inside the floor). + - **`anchors-resolve`** — every binding edge (`satisfies`, anchored `verifies`) naming the spec + originates from a binding node present in the graph. On extractor output this holds by + construction (anchors and their edges derive together); the clause has teeth for any other + graph producer and polices exactly the promise in its name: `implemented` stays *derivable + from a real binding* (the delivery-fact computation trusts edges). Pinned by a + synthetic-graph fixture, never decorative. + + **Cross-family double-fire accepted and named:** a `ready`-stating spec with a dangling + relation earns both the conformance error (the reference is broken) and the floor failure (the + stated rung is not earned) — two different statements, two families. + + **Evidence predicates become total.** Section content on a graph node is statically-reified + value data, never a typechecked instance — a malformed GWT entry must read as *absent + evidence*, never throw. (Typed sections remain the authoring-time guardrail; the floor reads + defensively.) + +4. **`AuthoredModel` retires wholesale** — the file, the type, the barrel export, and the four + pre-graph validators. `ExtractionResult.model` is replaced by + `counts: { specs, packs, anchors }` — the authored-carrier counts (duplicate-id carriers + included: the truthful record of what was authored even when ambiguity excludes it from the + graph), which is all the CLI summary ever consumed. Everything else the tests read off the + model re-keys to the graph — which is the point of MD-14. + +5. **`sdp validate [root] [--check-clean]` = the build pipeline + `validateGraph`.** Extraction + hard errors keep build semantics (exit 1, artifact withheld, stale artifact removed) and + short-circuit the checks — checking a partial graph would validate a phantom, the exact MD-14 + failure mode. With extraction clean, `graph.json` is written even when checks fail: the graph + *is* the faithful projection — check errors describe the **repo's** conformance, not the + artifact's — and the on-disk graph is what a human debugs the failure with. Check errors exit + 1; warnings inform. `--check-clean` rides the build half unchanged. + +6. **The CI gate.** `package.json` gains + `check:example` = built-CLI `validate examples/checkout-v1 --check-clean`, appended to `check` + after `build`; the CI workflow gains the matching step. The expected example outcome: **0 + errors, exactly 1 warning** — the invalid-cart example's unenabled verifier. That standing + warning is deliberate: the example keeps its "declared `verifies` without a test binding" + specimen (the Slice-2 honesty showcase), and Slice 3's surfacing of it *is* the demonstration. + +7. **Fixture strategy (`05` §5: every validator ships should-fail and should-pass pins).** + - `test/fixtures/authored-model.fixtures.ts` → + `test/fixtures/graph-validator.fixtures.ts`: fixtures keep their DSL builders and meaning; + a new `test/helpers/fixture-graph.ts` wraps the built values as reified carriers and runs + them through the **real `deriveGraph`** — no parallel derivation logic in tests (the + no-second-path discipline applied to fixtures). `fixtures.test.ts` drives `validateGraph`. + - The two reserved corpora activate on disk: + `invalid-ready-with-unresolved-dependency` (floor: `all-relations-resolve`, plus the + referential-integrity error — the named double-fire) · + `invalid-ready-with-target-below-defined` (floor: + `depends-on-and-refines-targets-are-defined`; extraction + referential integrity clean). + - New corpora, one pinned outcome each: + `invalid-hand-authored-delivery-fact-in-section` (the end-to-end MD-16 smuggle: defused + corpus → reified section → graph → `honesty/authoring-shape`; the strongest pin of the + bypass, beside the existing DSL-level fixture) · `invalid-duplicate-pack-member` · + `invalid-non-model-modelref` (F4) · `non-example-verifier` (warn) · `orphan-spec` (warn) · + `ready-without-verifier` (the gap, warn). The existing `unenabled-verifier` corpus gains the + `verifies-linkage` warning assertion. + - **Synthetic-graph fixtures** (hand-built `GraphSchema` values) pin the validators whose teeth + only show on non-extractor graphs: `duplicate-ids`, `claim-separation`, `anchors-resolve`, + edge-`from` resolution, and the did-you-mean suggestion. Legitimate inputs: the graph is the + public seam, and a foreign producer is a real input class. + +8. **Doc reconciliation, no temporal noise.** `05` §2's stand-in paragraph settles ("the pre-graph + `AuthoredModel` stand-in retired into the extractor when the one path closed" → state the + settled rule, drop the journey); the glossary's **pre-graph** locked-usage entry loses its + "fences stand-in checks" present tense; DECISIONS MD-14's header annotation marks execution + complete; `src` doc-comments that point forward at "Slice 3" state the settled behavior + instead. The roadmap (`07`) needs no edit — slice names are roadmap-relative and stay. + +## §2 — Work items + +### 1. The graph index (S) + +- New `src/validate/graph-index.ts`: `GraphIndex { nodesById, primitivesById, edgesByFrom, + edgesByTo }` + `buildGraphIndex(graph)`. Built once per `validateGraph` run; the floor + signature uses it. Exported via the barrel. + +### 2. The floor re-key (M) + +- `src/validate/readiness-floor.ts`: predicate signature `(node, index)`; envelope clauses read + node fields; intent/evidence clauses read `node.sections` defensively; relation clauses read + declared authored edges; promotion clauses walk `refines`/`constrainedBy` edges into child/target + nodes; the three `ready` clauses (§1.3) gain predicates; `GraphReadinessClause` retires. + +### 3. The graph validators (M) + +- `src/validate/validators.ts` rewritten: the nine validators of §1.2, `validateGraph`, + `graphValidatorIds`. The did-you-mean helper is a small bounded edit-distance scan over node + ids (unique best candidate at distance ≤ 2, else no suggestion — determinism over cleverness). +- `src/validate/contracts.ts`: `Validator` (the noun keeps its contract; + the default re-points to the one seam). Severities and `Finding` unchanged. + +### 4. The extractor demotion (S) + +- `src/validate/authored-model.ts` deleted; `src/index.ts` barrel updated. +- `src/extract/index.ts`: `ExtractionResult.model` → `counts`; doc-comments state the settled + one-path shape. +- `src/extract/derive.ts` / `src/graph/schema.ts`: forward-pointing comments settle (the + referential-integrity check flags dangling sentinels; pack coherence reads `modelRefs`). + +### 5. CLI: `sdp validate` (M) + +- `src/cli/sdp.ts`: the build flow refactors to return its graph; `validate` runs it, then + `validateGraph`, prints check findings in the one finding format, then + `validate: N errors · M warnings (conformance + honesty over the one graph)`; exit per §1.5. + Help text updated for both commands. + +### 6. The CI gate (S) + +- `package.json`: `check:example` (built CLI over the example, `--check-clean`); `check` appends + it after `build`. `.github/workflows/ci.yml`: the matching step. + +### 7. Fixtures + corpora (L) + +- Per §1.7: the fixture-graph helper, the renamed fixture module, the two activations, the six + new corpora, the synthetic-graph fixtures, the `unenabled-verifier` extension. + +### 8. Test re-keys (M) + +- `test/checkout-v1.test.ts`: model assertions → graph assertions; the clean-validation test pins + 0 errors + exactly the one unenabled-verifier warning. +- `test/extract.test.ts`: `result.model` → `result.counts`/graph; `validateAuthoredModel` → + `validateGraph`; dangling corpora expect `conformance/referential-integrity`. +- `test/cli.test.ts`: validate wired (example passes; a failing corpus exits 1 listing graph + findings; the not-wired stub test retires); help-text expectations. +- `test/readiness.test.ts` / `test/validators.test.ts` / `test/fixtures.test.ts`: re-keyed to + nodes + index / `validateGraph`. +- `test/readiness.typecheck.ts`: `AuthoredModel` block retires; `Validator` defaults to + `GraphSchema`. + +### 9. Docs + done-record (S) + +- Per §1.8; `npm run check` green end-to-end; this header becomes the done-record. + +## §3 — Verification (the done gate) + +1. `npm run check` green end-to-end (temporal guard · typecheck ×2 · lint · format · tests · + build · **the example validate gate**). +2. The golden is **byte-identical to before this slice** (no schema growth, no derivation change) + and the determinism self-check still passes. +3. `sdp validate examples/checkout-v1 --check-clean` exits 0 with 0 errors and exactly 1 warning + (the invalid-cart unenabled verifier), and writes the same `graph.json` as `sdp build`. +4. The two reserved fixtures are active corpora pinning their floor clauses; every §1.2 validator + has at least one should-fail and one should-pass pin (`05` §5). +5. A corpus with a dangling reference fails `sdp validate` (exit 1) while still building (exit 0) + — the gate is the checks, the build is the projection. +6. No `AuthoredModel` symbol survives in `src/` or the barrel; the only validation entry point is + `validateGraph` (plus the floor evaluator it composes). +7. Done-record header written at session end (Status: executed; Next: Slice 4 — the reader / + agent surface + the Design Review view, both fully derived; the minimal advisory inferred set + decides there with its consumer). + +## §4 — Explicit non-goals (deferred by decision, not omission) + +- **The derived-readiness banner** ("stated `defined`, derived `scoped`") → Slice 4 with the view; + the floor evaluator already reports which clause fails, which is the enabling half (`07` §6 ③). +- **The reader / agent surface / Design Review / impact** → Slice 4; **`coverage-unknown`** is a + Slice-4 acceptance criterion on blast-radius, not a graph validator. +- **The inferred layer** — still zero `inferred` claims; decided with its Slice-4 consumer. +- **Severity configuration / `--lenient`** — orphans and gaps default to warnings; per-config + escalation stays aspirational (`05` §6). +- **Error-message polish beyond did-you-mean, `sdp explain`/`search`** → Slice 5. +- **An informative id-shaped-prose surfacing** (the Slice-2 boundary note): assessed here as + *not* earning its way in — it polices prose by construction (MD-1 guardrail 1: checks never + judge content), and the `ref(…)` guard + corpus already pin the typed affordance. Recorded as + the standing answer unless real usage reopens it. +- **The decision-spec fold** (DECISIONS durables → `kind:"decision"` specs) — after this slice as + before; the validators land first so the fold's corpus is born checked. + +## §5 — Risks + +- **The example's standing warning** breeds warning-blindness if it multiplies. Accepted at one: + it is the differentiator made visible (a surfaced absence on the canonical example), CI gates on + errors only, and the Slice-4 view will render it as the teaching surface it is. +- **Defensive evidence predicates** can read a malformed-but-intended evidence shape as absent — + a floor failure with a confusing cause. Bounded: typed sections catch the shape at `tsc` for + real adopters; the corpus pins the no-throw behavior; the failure message names the clause. +- **`anchors-resolve` never fires on extractor output** and could read as decorative. Its + doc-comment names the producer class it polices and the synthetic fixture proves it fires; + MD-13's no-decorative-metadata rule is satisfied by a real predicate. +- **Validator-id rename** (`conformance/dangling-references` → `conformance/referential-integrity`) + changes finding output. Zero adopters; the ratified noun wins now or never. +- **Double-fire noise** (conformance error + floor failure on the same dangling relation of a + `ready` spec). Accepted and bounded: two families, two statements; only `ready`-stating specs + ever see both. diff --git a/plans/09-slice4-reader-and-design-review.md b/plans/09-slice4-reader-and-design-review.md new file mode 100644 index 0000000..3f700e6 --- /dev/null +++ b/plans/09-slice4-reader-and-design-review.md @@ -0,0 +1,265 @@ +# Plan 09 — Slice 4: the agent surface (the reader) and the Design Review + +> **Status: ✅ EXECUTED 2026-06-10** — all five work items landed on `feature/anchors`; +> `npm run check` green end-to-end, closing with the re-pointed gate (`check:example` = the built +> CLI running `view examples/checkout-v1 --check-clean`: 0 errors · the 1 standing warning · +> 11 pages). The consumer half of the MVP story (`06` §10) now exists: `createReader(graph)` is +> the one decode path (joins · `claim` decode · recomputed delivery facts · derived readiness · +> findings, once at construction), `renderDesignReview(reader)` is the one human view (Markdown: +> index + per-spec + per-pack pages, golden-pinned byte-for-byte), and `sdp view` = `sdp validate` +> + render with the view directory owned wholesale (temp-then-rename; a stale page never survives; +> on extraction hard errors the stale view is removed exactly as the stale graph). The **graph +> golden is byte-identical to the Slice-3 bytes** (this slice only reads), `deriveReadiness` rides +> the same floor table (MD-13 — no second floor), and the inferred set is **decided: empty** +> (§1.4 — the consumers resolve off the curated layers; `03` §1 / `07` §2 state the settled rule). +> +> **Execution deviations, all minor:** (1) the planned separate per-spec "Impact" section +> duplicated the relations list line-for-line — merged into one **"Relations & impact (one hop)"** +> section carrying both readings (information stated once, JS-E1/JS-G1 both served); (2) only +> *incoming* `verifies` edges render under Bindings — a verifier's own page must show what it +> covers (JS-G2), so *outgoing* `verifies` stays in the relations list; (3) the example itself +> turned out to demonstrate the honest divergence direction — every spec states `defined` and +> structurally clears `ready`, rendered as header information with no banner (the dishonest +> direction is pinned by a divergent fixture); (4) `.prettierignore` gained the golden view tree +> and `generated/` (derived bytes are the renderer's, never format-policed — the +> `expected-graph.json` precedent extended); (5) `findByConcept` counts `model.terms` *keys* as +> content (the vocabulary section's keys are the terms) while structural keys never match. +> +> **Next session: Slice 5 — polish**: the CLI surface (`explain`/`search` candidates weighed +> against the second-caller bar), error-message quality, the documented example walkthrough, and +> the clean-repo determinism test; the decision-spec fold (DECISIONS durables → `kind:"decision"` +> specs) follows the slices as before. +> +> **Spec anchors:** `06` §3 (the agent surface — a typed graph the agent scripts; the reader; the +> freeze discipline: entry adapters · file-level blast-radius · irreducible joins, everything else +> a script) · `06` §2 (two surfaces; the MVP impact boundary — file-level, `coverage-unknown`, +> never claims exhaustive reach) · `06` §5 (the Design Review; the one MVP read-only view; form is +> a Representation) · `05` §3 (stated vs derived readiness — the divergence is surfaced) · +> `03` §1/§4 (consumers read the graph and only the graph; links to recorded source locations are +> legitimate, independent re-parsing is not — R2) · `07` §6 ③ (the derived-readiness banner) · +> ④ (`implemented` is a UI hazard — binding language in views) · ⑤ (`coverage-unknown` is a +> Slice-4 acceptance criterion) · JTBD JS-E1 (one generated view), JS-E2 (the agent surface), +> JS-E4 (conduct a Design Review), JS-G1 (impact before change), JS-G2 (trace spec ↔ test), +> JS-G4 (specs that still need a verifier). + +## Context + +Slice 3 closed the one validation path: `validateGraph` is the sole validation seam, the example +validates to 0 errors and exactly 1 warning (the invalid-cart example's unenabled verifier), and +CI gates on the built CLI. What exists is a producer stack — extract → graph → checks — with no +consumer: nothing joins the graph for an agent, and no human surface renders it. The inferred +layer carries zero claims, deferred to be "decided with its Slice-4 consumer." + +Slice 4 delivers the consumer half of the MVP story (`06` §10): the **reader** (the component +behind the agent surface) and the **Design Review** (the one generated read-only view), both pure +functions of the one graph. No schema growth: `schemaVersion` stays `0.3.0`, the graph golden +stays byte-identical — everything this slice adds reads the graph; nothing writes it. + +## §1 — Decisions this plan pins (Representation, not DECISIONS.md entries) + +1. **The reader is the one decode path; the view consumes the reader.** `createReader(graph)` + does the joins, the `claim` decode, the delivery-fact recomputation, derived readiness, and the + validation findings **once at construction** (`06` §3); accessors return plain, composable, + JSON-able data, deterministically sorted; the reader persists nothing and is rebuilt fresh each + load. The Design Review renderer consumes **only** reader data — it never re-joins the graph. + One join path, exactly as MD-14 made one validation path: a view that re-derived its own joins + would be the consumption-side second store. The reader *calls* `validateGraph` and + `computeDeliveryFacts` (the existing seams) — it re-implements neither; exposed delivery facts + are the **recomputed** ones (identical on extractor output by construction, fail-closed for a + foreign producer — the same posture `honesty/gaps` took in Slice 3, and the divergence is + already the delivery-facts check's error, which the reader surfaces as findings). + +2. **The frozen surface** — exactly the `06` §3 irreducible set, nothing more: + - **`findByConcept(text)`** — the grep→graph bridge from a *string*: deterministic, + case-insensitive substring match over node id, title, pack framing, and section prose + (rules, example lines, terms, intent fields); results carry *where* they matched and sort by + (best-matched field rank, id). No fuzzy scoring — determinism over cleverness. + - **`byFile(path)`** — the bridge from a *file*: a root-relative POSIX path → the nodes the + graph records at it (specs/packs authored there; anchors and code nodes bound there) and the + specs those bindings reach. Resolves off the curated graph + anchors, no symbol index. + - **`blastRadius(changedFiles)`** — the bridge from a *changeset* (file-level, `06` §2): + changed files → `byFile` → directly **impacted** specs/packs → one explicit curated-graph hop + to **at-risk** items (declared `refines`/`dependsOn` in both directions, plus the verifiers + and implementations bound to impacted specs), each carrying *why* (the connecting edge type, + direction, and `claim` — JS-G1 AC4/AC5). A changed file with no graph mapping is an explicit + **`coverage-unknown`** entry, never silently dropped (⑤). One hop, reasons named: deeper + reach is a script over the same shapes; symbol-level reach is the aspirational impact graph. + - **`specContext(id)`** — the irreducible cross-source join: envelope + sections + relations + out/in (resolution + target titles decoded) + bindings (implementations; verifiers with + enabled-status) + recomputed delivery facts + stated vs derived readiness + the spec's own + findings. This is simultaneously what the Design Review renders per spec — the second + consumer that justifies freezing it. + - **`packContext(id)`** — the pack reviewed as a unit (JS-E4 AC4): framing, members with their + readiness/facts, `modelRefs`, and the pack's verifier gaps (JS-G4 — `ready` members missing + a verifier highlighted as the priority slice). + - Flat accessors (`specs()` · `packs()` · `findings()`) and the raw `graph`, so everything + else — single-field traversals, group-bys, the maturity ladder — stays a script. + - **`bySymbol` is not stubbed.** A typed method that throws "not implemented" would fake a + capability exactly as under-typing hides one; its frozen *shape* stays prose (`06` §3) until + the exhaustive impact graph exists. Pinned by a `@ts-expect-error` typecheck fixture. + +3. **Derived readiness lands beside the floor — same table, no second floor (MD-13 preserved).** + `deriveReadiness(node, index)` in `readiness-floor.ts`: the highest rung whose cumulative + clauses all pass (`undefined` when even `idea`'s fail); total over unratified descriptors + (no clauses evaluated — the conformance error owns that finding, exactly as the evaluator). + The banner (③) is then pure rendering: *stated* from the envelope, *floor reached* derived, + the first unmet clause named by the existing evaluator. Divergence renders loud only in the + dishonest direction (derived < stated); derived ≥ stated is ordinary header information — + the floor is never a quota and never nags upward. + +4. **The minimal advisory `inferred` set is decided: empty.** The deferred decision meets its + consumer and the consumer needs zero inferred edges — the MVP boundary (`06` §2) already + resolves `findByConcept`/`byFile`/blast-radius off the curated layers with no symbol index. + So the MVP ships the `inferred` **category** (typed in the schema, decoded by the reader, + rendered distinguishably, marked advisory in impact answers — JS-G1 AC5) with **zero + producers**; the first producer is the aspirational impact graph. Additive, not hard to + reverse, and forced by the base — recorded here, not in DECISIONS.md. `03` §1 and `07` §2 + state the settled rule. + +5. **The view is generated Markdown under `generated/design-review/`.** Form is a Representation + (`06` §5); Markdown wins the MVP: byte-exact determinism is trivial, the artifact is readable + by both remaining consumers (humans in any renderer, agents as text), and there is no asset + pipeline. Layout: `index.md` (counts · spec table with stated/derived readiness and binding + badges · packs · the full findings table · gaps) plus one page per `Primitive` and per `Pack`. + The id→path mapping is the bijective namespace split (`spec:orders.create-order` → + `spec/orders.create-order.md`) — collision-free by the id grammar (exactly one `:`, segments + filesystem-safe). Anchor/code nodes get no pages; they render as bindings with `file:line` + source links (links to locations *recorded in the graph* — R2). Pages carry no timestamps and + no commit hashes: the view is `f(graph)`, nothing else. + +6. **Views speak binding language; internals keep the fact names (④, MD-7).** + `implemented`/`has-verifier` stay the internal fact names (they power the drift/backlog + queries); the view renders *"Implementation binding: present/none · Verifier binding: + present/none · Runtime observation: not tracked"*. The example's standing unenabled-verifier + warning renders in context as the teaching surface it is (the Slice-3 risk note resolved). + +7. **CLI: `sdp view [root] [--check-clean]` = `sdp validate` + render.** The pipeline extends one + stage: build → checks → render; exit semantics stay validate's (extraction hard errors keep + build semantics and short-circuit; check errors exit 1 with **both** artifacts written — the + graph and the view are faithful projections, and a view that refused to render findings would + hide exactly what it exists to show). The renderer owns `generated/design-review/` wholesale — + removed and rewritten every run, so a deleted spec's page cannot survive as a stale artifact; + `generated/graph.json` stays `build`'s. `--check-clean` extends to the view: two independent + pipeline runs must produce byte-identical pages. `check:example` re-points to + `view --check-clean` (it subsumes validate), and the CI step renames accordingly. + +## §2 — Work items + +### 1. Derived readiness (S) + +- `src/validate/readiness-floor.ts`: `deriveReadiness` per §1.3; doc-comment states the + stated-vs-derived split (`05` §3). `test/readiness.test.ts`: cleared-rung cases, the + divergence case, the unratified-descriptor case, derived > stated. + +### 2. The reader (L) + +- New `src/reader/reader.ts` (barrel-exported): `createReader(graph): Reader` + the §1.2 surface + and its plain-data result types. Construction: `buildGraphIndex` + `computeDeliveryFacts` + + `validateGraph` + `deriveReadiness` — existing seams only. All outputs sorted (code-unit + comparison, the established currency). +- `test/reader.test.ts`: the example graph end-to-end (entry adapters · blast radius with + `coverage-unknown` · `specContext` joins with `claim`s decoded · `packContext` gaps · + findings reachable — JS-E2 AC4); synthetic foreign-producer graphs (an `inferred` edge decoded + and marked advisory; stated-but-unearned facts exposed as recomputed + the honesty finding); + determinism (two readers over the same graph, identical answers; input graph never mutated). +- `test/reader.typecheck.ts`: the surface's types + the `@ts-expect-error` pin that `bySymbol` + does not exist. + +### 3. The Design Review renderer (L) + +- New `src/projections/design-review.ts`: `renderDesignReview(reader): readonly Page[]` + (`{ path, content }` — pure, fs-free). Per-spec page: header (title · kind with display label · + altitude · stated readiness · floor reached · the ③ banner with the first unmet clause when + derived < stated) · bindings in binding language (⑥) with source links · intent (open questions + with `blocking` loud) · behavior/constraints/model/decision/verification rendered per their + typed shapes (`design`/`ui` generically) · relations out/in with `claim` cues and page links · + the impact list (the spec's one-hop neighborhood + bindings, from `specContext`) · the spec's + findings and gaps. Per-pack page: framing · member table · `modelRefs` · verifier gaps. Index + per §1.5. +- Golden: `test/fixtures/checkout-v1/expected-design-review/**` — the committed expected tree for + the example (the correctness oracle, exactly as `expected-graph.json`; legitimate because it + lives in `fixtures/`, never `generated/`). +- `test/design-review.test.ts`: golden comparison (every page byte-identical, no extra/missing + pages); semantic pins independent of the golden — the ④ binding language, the ③ banner on a + divergent synthetic node, `claim` cues, the standing warning rendered in context, a blocking + open question rendered loud. + +### 4. CLI + the gate (M) + +- `src/cli/sdp.ts`: `view` per §1.7; help text. `package.json`: `check:example` → built-CLI + `view examples/checkout-v1 --check-clean`; `.github/workflows/ci.yml` step name follows. +- `test/cli.test.ts`: view writes the tree and exits 0 on the example (1 standing warning); + a stale page is removed on re-render; delete-`generated/`-and-rerun is byte-identical; + `--check-clean` passes; the dangling-relation corpus renders the view *and* exits 1; the + hard-error corpus writes neither artifact; help text. + +### 5. Docs reconciliation (S — settled rules, no temporal noise) + +- `03` §1: the structural-facts bullet states the inferred layer ships empty (§1.4). +- `05` §3: the stated-vs-derived note settles — the banner ships in the view, enabled by the + floor evaluator naming the failing clause. +- `06` §5: the form sentence settles on generated Markdown (HTML/Studio stays aspirational §8). +- `07` §2: the CORE map's `claim` parenthetical reflects §1.4; §4's banner-timing open question + records as resolved (the pattern the impact-depth bullet already uses). +- Glossary: no new terms — `reader`, `Design Review`, `agent surface`, `impact graph` already + carry the ratified definitions this slice implements. +- No new DECISIONS.md entries: every §1 pin is Representation-level or base-forced (the + three-part test admits none). + +## §3 — Verification (the done gate) + +1. `npm run check` green end-to-end, closing with the re-pointed `check:example` (validate + + view + determinism over both artifacts). +2. The graph golden is **byte-identical to the Slice-3 bytes** (no schema growth; the reader and + view only read). +3. `sdp view examples/checkout-v1 --check-clean` exits 0 (0 errors · the 1 standing warning), + writes `generated/design-review/` matching the committed expected tree, and the create-order + page shows: binding language, both implementation bindings, the enabled valid-cart verifier + distinguished from the unenabled invalid-cart one, `claim` cues, and the impact list. +4. `blastRadius` over a changed `create-order.use-case.ts` reaches the spec it satisfies and its + one-hop neighborhood with reasons; an unanchored changed file comes back as `coverage-unknown` + (⑤ — the Slice-4 acceptance criterion). +5. The derived-readiness banner (③) is pinned by a divergent fixture; the example (whose specs + honestly state their floors) renders no banner. +6. Findings, gaps, and open questions are reachable through the reader (JS-E2 AC4) and visible in + the view in context (JS-E1 AC5). +7. Done-record header written at session end (Status: executed; Next: Slice 5 — polish: CLI + `explain`/`search` candidates, error messages, the documented example, the clean-repo + determinism test). + +## §4 — Explicit non-goals (deferred by decision, not omission) + +- **Intent composition / any write affordance** — the view is read-only; the edit loop stays + *intent → agent → git → conformance checks* (`06` §4); no patch subsystem. +- **`bySymbol`, symbol-level reach, the exhaustive impact graph** — aspirational (`06` §2/§3); + blast-radius stays file-level and says so. +- **Token-budgeted context bundles, the MCP surface, GraphRAG** — later layers over the same + read-only model (`06` §3/§7). +- **HTML / Spec Studio / interactive trees** — aspirational (`06` §8); one Markdown view proves + derivation. +- **A `git diff` runner inside the reader** — `blastRadius` takes changed paths; shelling out to + git is the caller's (CLI-polish or agent-script) concern, and keeping the reader pure keeps it + deterministic and testable. +- **Severity configuration, `sdp explain`/`search`, error-message polish** → Slice 5. +- **The decision-spec fold** (DECISIONS durables → `kind:"decision"` specs) — after the slices, + as before. + +## §5 — Risks + +- **The golden view tree churns on copy edits.** Accepted: that is what a correctness oracle is + for — a wording change should show up as a reviewable diff; the semantic pins in + `design-review.test.ts` keep meaning-level regressions caught independently of bytes. +- **Markdown link targets break silently** (a relation to a node with no page — anchors, code + nodes, dangling targets). Bounded: only `Primitive`/`Pack` targets link; everything else + renders as plain code text; dangling targets render named-but-unlinked with their referential- + integrity finding beside them. +- **The reader's findings exposure could read as a second validation path.** It is not: the + reader *calls* `validateGraph` (the one seam) and re-implements nothing; the doc-comment says + so. Same shape as the CLI's own composition. +- **One-hop blast-radius can under-report transitive reach.** Named in the answer itself: the + result is labeled one-hop, deeper walks are scripts over the same shapes, and `coverage-unknown` + keeps the file-level blind spot loud (never claims exhaustive reach, `06` §2). +- **`packContext` gaps vs `honesty/gaps` divergence** — two surfaces could disagree about "needs + a verifier." Bounded: both read the same recomputed facts; the pack view lists *all* members + without a verifier and highlights the `ready` ones (JS-G4 AC3), while the validator warns only + on `ready` — a deliberate superset, documented where rendered. diff --git a/plans/10-slice5-polish.md b/plans/10-slice5-polish.md new file mode 100644 index 0000000..05aff93 --- /dev/null +++ b/plans/10-slice5-polish.md @@ -0,0 +1,176 @@ +# Plan 10 — Slice 5: polish (the CLI surface resolved, one diagnostic rule, the documented example, the clean-repo determinism test) + +> **Status: ✅ EXECUTED 2026-06-11** — all five work items landed on `feature/anchors`; +> `npm run check` green end-to-end (157 tests; the golden view tree re-pinned with the Where +> column — the expected three-page churn: index + the two pages carrying the standing warning; +> `check:example` = view `--check-clean`: 0 errors · the 1 standing warning · 11 pages). The MVP +> slice roadmap (`07` §1) is complete: the CLI surface is **resolved** (`build` · `validate` · +> `view`; `explain`/`search` stayed below the second-caller bar — §1.1, settled in `00` §4 / +> `07` §1+§3 / `AGENTS.md`), diagnostics follow **one rendering rule** (location from the +> structured `file`/`line` fields, printed exactly once — the CLI prefix and the view's Where +> column; the three message-embedding sites dropped), **first contact fails clean** (a bad root +> is a one-liner + exit 1, never a stack trace; a zero-spec root builds honestly and says where +> it looked), the **clean-repo determinism test** pins location-independence (the full pipeline +> at a different absolute path, byte-identical over `graph.json` + every page), and the example +> carries its **documented walkthrough** (`examples/checkout-v1/README.md` — every command and +> break-it claim executed before being written down: referential-integrity + did-you-mean · +> readiness-floor naming `no-blocking-open-questions` · the second verifies-linkage warning at +> exit 0 · authoring-shape + the TS2353 closed-section rejection). +> +> **Execution deviations: none material.** The help text needed no edit (nothing it states +> changed); the Where column rode the one shared findings renderer, so the index and +> per-spec/per-pack tables moved together. +> +> **Next session: the decision-spec fold** — DECISIONS durables → `kind:"decision"` specs under +> the reserved ids (the registry's "Future spec id" column), now that the slices are complete. +> +> **Spec anchors:** `07` §1 (the slice table — "Polish: CLI (`sdp build`, `sdp validate`, maybe +> `explain`/`search`), error messages, the documented example, and a 'regenerate from clean repo' +> determinism test") · `06` §3 (the agent surface; the freeze discipline — "Freeze a typed +> contract only when a **second machine consumer** appears") · `07` §6 ① (authoring ergonomics — +> "great error messages" next; `sdp new spec` / `sdp explain` later) · `03` §2 (determinism; +> `--check-clean` is a self-comparison, never a diff against a committed artifact) · `00` §4 +> (the cut table's "Full CLI" row) · JTBD JS-C1 (`sdp build` / `--check-clean`), JS-D2 (a passing +> `sdp validate` is a credible statement), JS-B2 (rebuild after refactor, no manual repair). + +## Context + +Slices 1–4 grew the CLI incrementally: `build` · `validate` · `view`, each with `--check-clean`, +all-or-nothing artifacts, temp-then-rename writes, and the gate re-pointed at the built CLI. So +slice 5's "CLI" work is not building commands — it is **resolving the roadmap's "maybe"** +(`explain`/`search`), fixing the **first-contact failure modes** (a nonexistent root today dies +with a raw Node `ENOENT` stack trace), and applying the model's own information discipline to +**diagnostics** (location is currently stated twice: embedded in extractor messages *and* carried +in the structured `file`/`line` fields, while graph-validator findings carry the fields but the +CLI never prints them). The documented example walkthrough and the clean-repo determinism test +are the two genuinely new deliverables. + +## §1 — Decisions this plan pins (Representation, not DECISIONS.md entries) + +1. **The second-caller bar holds: no `explain`, no `search` — the MVP CLI is `build` · `validate` + · `view`.** The agent scripts the typed graph through the reader (`06` §3 — in-process, the + measured context-efficiency win); the human reads the Design Review (`sdp view`). A terminal + verb over the same joins would be a third renderer of the same information: for the agent it + is the raw-JSON-you-rejoin failure mode the agent surface explicitly rejects, for the human it + duplicates the per-spec page. No JTBD acceptance criterion names either verb. The roadmap's + "maybe" resolves to **not in the MVP**; the revisit trigger is measured pain (`07` §5), and + `07` §6 ① already orders `sdp explain` as a *later* ergonomics lever. Base-forced by the + freeze rule, additive to reverse — recorded here, not in DECISIONS.md (the three-part test + admits nothing). + +2. **One diagnostic rendering rule: location lives in the structured `file`/`line` fields; + renderers print it.** Finding *messages* stop embedding `file:line` (today duplicated at the + two extractor finding helpers and the duplicate-id site); the CLI formatter becomes the one + text rendering — `file:line — [severity] id — message` when location is known (`file` alone + when no line; bare `[severity] id — message` otherwise) — and the Design Review findings + tables gain a Where column from the same fields (a location *recorded in the graph* — R2). + Same information stated once: the discipline the model preaches, applied to its own + diagnostics. Pre-1.0, no foreign parser of the old format exists. + +3. **First contact fails clean.** A nonexistent or non-directory root is a one-line + `sdp : root "" is not a directory.` on stderr, exit 1 — never a stack trace. + An existing root with **zero spec files** still builds and exits 0 (an empty authored model is + *conformant* — absence of specs is not a finding, so the graph and report stay pure), but the + CLI says where it looked: a stderr note naming the resolved root and the `*.sdp.ts` discovery + rule, so a typo'd `cwd` is never a silent success. CLI-level by design — it is feedback about + the *invocation*, not a property of the authored model. + +4. **The clean-repo determinism test pins location-independence — the property the existing + checks cannot see.** `--check-clean` runs the pipeline twice over the *same* root; + delete-`generated/`-and-rerun reuses the same root too. Neither can catch an absolute path + leaking into artifact bytes. The new test copies the example's authored surfaces (`specs/` · + `src/` · `test/` — never `generated/`) to a temp root at a different absolute path, runs the + full pipeline there, and byte-compares `graph.json` and every view page against the in-repo + build. Deliberately a working-tree copy, **not** `git archive`: the test pins the projection + property — bytes are a function of the root's *content*, never its location or leftover local + state — and an uncommitted example edit must not fail a determinism test (commit-state fidelity + is the golden oracle's job, `07` §6 ②). + +5. **The documented example is a walkthrough README colocated with the example** + (`examples/checkout-v1/README.md`): what the example models, the three authored surfaces + (the spec files · the anchored implementation · the test anchor), the `build → validate → + view` walk with what to look at — including the **standing unenabled-verifier warning read as + the teaching surface it is** — and a break-it-on-purpose section demonstrating the trust model + (each experiment names the check that fires, verified by actually running it before it is + written down). It speaks the ratified language, points into `docs/concept/` instead of + restating the model, and passes `check:temporal` and `format:check` like any other doc. + +## §2 — Work items + +### 1. CLI diagnostics (M) + +- `src/cli/sdp.ts`: the root existence/directory guard (§1.3); `formatFinding` renders + `file[:line] — ` from the fields (§1.2); the zero-spec-files note (§1.3); help text untouched + except where it states behavior this item changes. +- `src/extract/reify.ts` · `src/extract/anchors.ts` · `src/extract/index.ts`: the three + message-embedded `file:line` sites drop the embedding (fields already carried). +- `test/cli.test.ts`: nonexistent-root one-liner + exit 1; zero-spec-files note; the + `file:line —` prefix asserted on a hard-error corpus run. `test/extract.test.ts` / + `test/fixtures.test.ts`: message assertions follow the de-duplicated messages. + +### 2. The Design Review findings location column (S) + +- `src/projections/design-review.ts`: the findings tables (index + per-page) gain Where + (`file:line` / `file` / `—`). Golden tree re-pinned; a semantic pin in + `test/design-review.test.ts` asserts the column renders from the fields. + +### 3. The clean-repo determinism test (S) + +- `test/cli.test.ts`: the §1.4 copy-to-temp-root test over the full pipeline + (`view --check-clean`), byte-comparing `graph.json` + every page against the in-repo build. + +### 4. The documented example walkthrough (M) + +- `examples/checkout-v1/README.md` per §1.5 — every command and every break-it claim executed + before being written down. +- `AGENTS.md` (the symlink target): the build-path section gains the walkthrough pointer. + +### 5. Docs reconciliation (S — settled rules, no temporal noise) + +- `00` §4 cut table, "Full CLI" row: settles to "MVP CLI is `sdp build` · `sdp validate` · + `sdp view`" with `explain`/`search` recorded as below the second-caller bar (revisit on + measured pain). +- `07` §1 slice-table row 5 and §3 cut #9: same settling; "maybe" leaves the docs. +- `AGENTS.md` slice-table row 5: follows `07`. +- Glossary: untouched — no new terms (walkthrough/diagnostics introduce no vocabulary). +- No new DECISIONS.md entries (§1.1 rationale). + +## §3 — Verification (the done gate) + +1. `npm run check` green end-to-end (including the re-pinned golden view tree and + `check:example`). +2. `sdp build /no/such/root` prints the one-line root error, exits 1, no stack trace; a + zero-spec root builds, exits 0, and prints the where-it-looked note. +3. A hard-error corpus run shows `file:line — [error] extract/…` once per finding — location + stated exactly once. +4. The clean-repo test passes: the example pipeline at a different absolute path is + byte-identical for `graph.json` and every view page. +5. Every command and break-it experiment in `examples/checkout-v1/README.md` was executed and + produced the documented outcome; the README passes `check:temporal` + `format:check`. +6. `00` / `07` / `AGENTS.md` state the resolved CLI surface; no doc says "maybe" about + `explain`/`search` anymore. +7. Done-record header written at session end (Status: executed; Next: the decision-spec fold — + DECISIONS durables → `kind:"decision"` specs — now that the slices are complete). + +## §4 — Explicit non-goals (deferred by decision, not omission) + +- **`sdp explain` / `sdp search`** — resolved out (§1.1); revisit rides measured pain (`07` §5). +- **`sdp validate --watch` · `sdp new spec`** — the named *later* authoring-ergonomics levers + (`07` §6 ①), not slice scope. +- **Severity configuration / the `--lenient` ratchet** — aspirational (`07` §2); severities stay + the validators' own. +- **`sdp --version`, publishing, npm packaging polish** — arrives with the package, not the MVP + loop. +- **The decision-spec fold** — the next session, after the slices (as every plan since the + registry has recorded). + +## §5 — Risks + +- **Golden view churn from the Where column.** Accepted — the oracle exists to make exactly this + reviewable as a diff; the semantic pins keep meaning-level regressions caught independently. +- **The walkthrough drifts as the example grows.** Bounded: the README documents the example it + sits beside, so the same PR that changes the example reviews the README; counts appear only in + illustrative command output, clearly tied to the commands that print them. +- **Message-format changes break an unseen consumer.** Pre-1.0, the CLI's text output has no + contract (the graph and the typed reader are the machine surfaces); the format change is the + point (§1.2). diff --git a/src/cli/sdp.ts b/src/cli/sdp.ts index 5d05eb2..642bd79 100644 --- a/src/cli/sdp.ts +++ b/src/cli/sdp.ts @@ -1,20 +1,57 @@ #!/usr/bin/env node +import { mkdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { extract } from "../extract/index.js"; +import { serializeGraph } from "../extract/serialize.js"; +import type { GraphSchema } from "../graph/schema.js"; +import { renderDesignReview } from "../projections/design-review.js"; +import { createReader } from "../reader/reader.js"; +import type { Finding } from "../validate/contracts.js"; +import { validateGraph } from "../validate/validators.js"; + export const SDP_HELP_TEXT = `sdp — Libar Software Delivery Protocol Usage: sdp --help - sdp build - sdp validate + sdp build [root] [--check-clean] + sdp validate [root] [--check-clean] + sdp view [root] [--check-clean] Commands: - build Not implemented yet (Slice 1: extractor) - validate Validation gate not wired yet (Slice 3: graph validator gate)`; + build Extract every *.sdp.ts under root (default: cwd), plus the anchor constants in the + other *.ts/*.tsx source files, into /generated/graph.json. + Exits 1 and writes nothing on any hard error — the emitted artifact is + all-or-nothing. --check-clean additionally runs a second independent extraction + and fails on any byte divergence (the determinism self-check). + validate build, then run the conformance + honesty checks over the one graph (one + validation path). A check error exits 1; gaps and orphans inform as warnings. + graph.json is still written when the checks fail — the graph is the faithful + projection; check errors describe the repo's conformance, not the artifact. + view validate, then generate the Design Review — the one read-only human view, a pure + projection of the graph — into /generated/design-review/ (rewritten + wholesale, so no stale page survives). The view is written even when checks + fail: findings render in it, which is what a review surface is for. Exit code + follows validate. --check-clean additionally re-renders independently and fails + on any byte divergence.`; interface CliOutput { stdout?: { write: (chunk: string) => void }; stderr?: { write: (chunk: string) => void }; } +/** + * The internal injection seam — never a CLI flag, never part of the agent surface. Extraction and + * rendering are deterministic (P3), so the --check-clean divergence branches and the error + * boundaries are unreachable from honest inputs; their coverage substitutes these producers. + */ +interface CliHooks { + readonly extract?: typeof extract; + readonly renderDesignReview?: typeof renderDesignReview; + readonly validateGraph?: typeof validateGraph; +} + const defaultCliOutput: CliOutput = { stdout: process.stdout, stderr: process.stderr, @@ -32,33 +69,341 @@ function writeStderr(output: CliOutput, text: string): void { } } -export function runSdpCli(args: readonly string[], output: CliOutput = defaultCliOutput): number { - const [command] = args; +/** + * The one text rendering of a finding: location comes from the structured `file`/`line` fields + * (messages never embed it — stating it twice is the duplication the model itself forbids). + * Graph-validator findings often carry `file` without `line` (`Primitive` nodes are line-free by + * design), so each part renders only when known. + */ +function formatFinding(finding: Finding): string { + const location = + finding.file === undefined + ? "" + : `${finding.file}${finding.line === undefined ? "" : `:${String(finding.line)}`} — `; + + return `${location}[${finding.severity}] ${finding.validatorId} — ${finding.message}\n`; +} + +interface BuildArgs { + /** The resolved extraction root. */ + readonly root: string; + readonly checkClean: boolean; +} + +function parseBuildArgs( + args: readonly string[], + output: CliOutput, + command: string, +): BuildArgs | undefined { + let root: string | undefined; + let checkClean = false; + + for (const argument of args) { + if (argument === "--check-clean") { + checkClean = true; + continue; + } + + if (argument.startsWith("--")) { + writeStderr(output, `sdp ${command}: unknown option ${argument}\n`); + return undefined; + } + + if (root !== undefined) { + writeStderr(output, `sdp ${command} takes at most one root argument.\n`); + return undefined; + } + + root = argument; + } + + const resolvedRoot = resolve(process.cwd(), root ?? "."); + + // First contact fails clean: a typo'd root is invocation feedback, never a Node stack trace. + if (!isDirectory(resolvedRoot)) { + writeStderr(output, `sdp ${command}: root "${resolvedRoot}" is not a directory.\n`); + return undefined; + } + + return { root: resolvedRoot, checkClean }; +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Best-effort stale-artifact removal for recovery paths. `force: true` is not enough on every + * supported runtime: Node 20 ignores only ENOENT, so a path through `generated`-as-a-file still + * raises ENOTDIR through the file parent — and recovery must never itself throw and crash out of + * the one-line law. A path whose parent is not a directory holds no readable artifact, so + * swallowing the failure leaves nothing stale behind. Success-path removals stay raw `rmSync`: + * there a swallowed failure could rename a stale temp tree into place, and the surrounding + * try/catch already routes the error into the one-line law. + */ +function removeArtifact(path: string): void { + try { + rmSync(path, { recursive: true, force: true }); + } catch { + // nothing readable exists at a path through a non-directory; the one-line law holds + } +} + +interface BuildOutcome { + readonly exitCode: number; + /** Present only when the build succeeded — the graph the checks consume. */ + readonly graph?: GraphSchema; +} + +function runBuild( + parsed: BuildArgs, + output: CliOutput, + command: string, + hooks: CliHooks, +): BuildOutcome { + const { root: resolvedRoot, checkClean } = parsed; + const runExtract = hooks.extract ?? extract; + const graphPath = join(resolvedRoot, "generated", "graph.json"); + + // A stale projection is as dishonest as a partial one: a failed build must not leave a previous + // graph.json behind that downstream consumers could read as current — nor a half-written temp + // twin. Recovery rides removeArtifact, which never throws — so it cannot crash out of the + // one-line law. + const failBuild = (message: string): BuildOutcome => { + removeArtifact(graphPath); + removeArtifact(`${graphPath}.tmp`); + writeStderr(output, message); + return { exitCode: 1 }; + }; + + try { + const result = runExtract({ root: resolvedRoot }); + const findings = result.report.findings; + + for (const finding of findings) { + writeStderr(output, formatFinding(finding)); + } + + // An empty authored model is conformant — no finding, exit 0 — but a typo'd cwd must never be + // a silent success, so the CLI (the invocation surface) says where it looked. A finding that + // names a spec file proves spec files were found (a failed file is not an absent one), so the + // note stays silent beside it. + if ( + result.counts.specs === 0 && + !findings.some((finding) => finding.file?.endsWith(".sdp.ts")) + ) { + writeStderr( + output, + `note: no *.sdp.ts spec files found under ${resolvedRoot} — the authored model is empty. Is this the right extraction root?\n`, + ); + } + + const errorCount = findings.filter((finding) => finding.severity === "error").length; + const warningCount = findings.length - errorCount; + const summary = `${String(result.counts.specs)} specs · ${String(result.counts.packs)} packs · ${String(result.counts.anchors)} anchors → ${String(result.graph.nodes.length)} nodes · ${String(result.graph.edges.length)} edges (${String(errorCount)} errors, ${String(warningCount)} warnings)\n`; + + if (errorCount > 0) { + writeStdout(output, summary); + return failBuild( + `sdp ${command}: hard errors present — graph.json not written; any previous graph.json at this root was removed.\n`, + ); + } + + const serialized = serializeGraph(result.graph); + + if (checkClean) { + const second = serializeGraph(runExtract({ root: resolvedRoot }).graph); + + if (second !== serialized) { + return failBuild( + `sdp ${command} --check-clean: two independent extractions diverged — the build is not deterministic; any previous graph.json at this root was removed.\n`, + ); + } + } + + // Temp-then-rename so a crash mid-write can never leave a truncated graph.json looking current. + const temporaryPath = `${graphPath}.tmp`; + mkdirSync(join(resolvedRoot, "generated"), { recursive: true }); + writeFileSync(temporaryPath, serialized, "utf8"); + renameSync(temporaryPath, graphPath); + writeStdout(output, summary); + writeStdout(output, `Wrote ${graphPath}\n`); + return { exitCode: 0, graph: result.graph }; + } catch (error) { + // Failures past root discovery keep the same law as the typo'd root: one line of invocation + // feedback, exit 1, never a Node stack trace — and the stale-artifact removal above holds. + return failBuild(`sdp ${command}: ${errorMessage(error)}\n`); + } +} + +/** + * `sdp validate` = `sdp build` + the checks (one validation path, MD-14). Extraction hard errors + * keep build semantics and short-circuit the checks — checking a partial graph would validate a + * phantom. With extraction clean the artifact is written even when checks fail: the graph is the + * faithful projection, and the check errors describe the repo's conformance, not the artifact. + */ +function runValidate( + parsed: BuildArgs, + output: CliOutput, + command: string, + hooks: CliHooks, +): BuildOutcome { + const build = runBuild(parsed, output, command, hooks); + + if (build.graph === undefined) { + return build; + } + + const runValidateGraph = hooks.validateGraph ?? validateGraph; + + try { + const findings = runValidateGraph(build.graph).findings; + + for (const finding of findings) { + writeStderr(output, formatFinding(finding)); + } + + const errorCount = findings.filter((finding) => finding.severity === "error").length; + const warningCount = findings.length - errorCount; + writeStdout( + output, + `validate: ${String(errorCount)} errors · ${String(warningCount)} warnings (conformance + honesty over the one graph)\n`, + ); + + return { exitCode: errorCount > 0 ? 1 : 0, graph: build.graph }; + } catch (error) { + // The checks ride the same one-line law as every stage past discovery. graph.json stays — it + // was cleanly built, and the failure describes the checks, not the artifact. + writeStderr(output, `sdp ${command}: ${errorMessage(error)}\n`); + return { exitCode: 1 }; + } +} + +/** + * `sdp view` = `sdp validate` + the Design Review render. The view directory is owned wholesale — + * removed and rewritten every run (a deleted spec's page must not survive as a stale artifact), + * via temp-then-rename so a crash mid-write never leaves a half-written tree looking current. + * The view is written even when checks fail: findings render *in* it — a review surface that + * refused to show findings would hide exactly what it exists to show — so the exit code is + * validate's, the artifacts stay. + */ +function runView(parsed: BuildArgs, output: CliOutput, hooks: CliHooks): number { + const render = hooks.renderDesignReview ?? renderDesignReview; + const viewPath = join(parsed.root, "generated", "design-review"); + const validate = runValidate(parsed, output, "view", hooks); + + if (validate.graph === undefined) { + // Build semantics: no graph, no view — and a stale view from a previous run is as dishonest + // as a stale graph.json, so it goes the same way (never-throw: this runs outside the + // try/catch, and a `generated`-as-a-file root must still fail on build's one line). + removeArtifact(viewPath); + return validate.exitCode; + } + + // The graph's stale-artifact law, applied to the view: a failed render must not leave a + // previous design-review behind that a reviewer could read as current — nor a partial temp + // tree from a write that failed mid-loop. + const temporaryPath = `${viewPath}.tmp`; + const failView = (message: string): number => { + removeArtifact(viewPath); + removeArtifact(temporaryPath); + writeStderr(output, message); + return 1; + }; + + try { + const pages = render(createReader(validate.graph)); + + if (parsed.checkClean) { + const second = render(createReader(validate.graph)); + + if (JSON.stringify(second) !== JSON.stringify(pages)) { + return failView( + "sdp view --check-clean: two independent renders diverged — the view is not deterministic; any previous design-review at this root was removed.\n", + ); + } + } + + rmSync(temporaryPath, { recursive: true, force: true }); + + for (const page of pages) { + const target = join(temporaryPath, page.path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, page.content, "utf8"); + } + + rmSync(viewPath, { recursive: true, force: true }); + renameSync(temporaryPath, viewPath); + writeStdout(output, `Wrote ${viewPath} (${String(pages.length)} pages)\n`); + return validate.exitCode; + } catch (error) { + // Failures past root discovery keep the same law as the typo'd root: one line of invocation + // feedback, exit 1, never a Node stack trace — and the stale-artifact removal above holds. + return failView(`sdp view: ${errorMessage(error)}\n`); + } +} + +export function runSdpCli( + args: readonly string[], + output: CliOutput = defaultCliOutput, + hooks: CliHooks = {}, +): number { + const [command, ...rest] = args; if (command === undefined || command === "--help") { writeStdout(output, `${SDP_HELP_TEXT}\n`); return 0; } - if (command === "build") { - writeStderr(output, "sdp build is not implemented yet (Slice 1: extractor).\n"); + if (command !== "build" && command !== "validate" && command !== "view") { + writeStderr(output, `${SDP_HELP_TEXT}\n\nUnknown command: ${command}\n`); return 1; } - if (command === "validate") { - writeStderr(output, "sdp validate gate is not wired yet (Slice 3: graph validator gate).\n"); + const parsed = parseBuildArgs(rest, output, command); + + if (parsed === undefined) { return 1; } - writeStderr(output, `${SDP_HELP_TEXT}\n\nUnknown command: ${command}\n`); - return 1; + if (command === "build") { + return runBuild(parsed, output, "build", hooks).exitCode; + } + + if (command === "validate") { + return runValidate(parsed, output, "validate", hooks).exitCode; + } + + return runView(parsed, output, hooks); } -const executedPath = process.argv[1]; +/** + * True when this module is the executed entry point. npm exposes the CLI as a + * `node_modules/.bin/sdp` symlink and Node keeps the symlink path in `process.argv[1]`, so a + * path-suffix check would silently no-op for the installed binary; realpath-comparing both sides + * recognizes every route to the entry file (direct, symlinked, or behind a symlinked directory). + * Fails closed: an unresolvable path means we are not the entry point. + */ +export function isCliEntrypoint(executedPath: string | undefined, moduleUrl: string): boolean { + if (executedPath === undefined) { + return false; + } + + try { + return realpathSync(executedPath) === realpathSync(fileURLToPath(moduleUrl)); + } catch { + return false; + } +} -if ( - executedPath !== undefined && - (executedPath.endsWith("/dist/cli/sdp.js") || executedPath.endsWith("\\dist\\cli\\sdp.js")) -) { +if (isCliEntrypoint(process.argv[1], import.meta.url)) { process.exitCode = runSdpCli(process.argv.slice(2)); } diff --git a/src/extract/anchors.ts b/src/extract/anchors.ts new file mode 100644 index 0000000..ec93312 --- /dev/null +++ b/src/extract/anchors.ts @@ -0,0 +1,382 @@ +import { Node, VariableDeclarationKind } from "ts-morph"; +import type { CallExpression, ObjectLiteralExpression, SourceFile } from "ts-morph"; + +import { CODE_ANCHOR_NAMESPACES } from "../ids.js"; +import type { Finding, Severity } from "../validate/contracts.js"; +import { + collectProtocolBindings, + duplicatePropertyMessage, + extractFindingIds, + peekId, + readPropertyName, + reifyStaticIdExpression, + reifyStaticString, + resolveBuilderCall, + resolveProtocolCalleeBuilder, + unwrapTransparent, +} from "./reify.js"; +import type { IdReification, ProtocolBindings } from "./reify.js"; + +/** + * Anchor reification — the anchored layer's producer half (`04` §2). Source files are real product + * code, so there is no recognized-statement sweep here (the opposite of spec files): the extractor + * only looks for the anchor-constant form — a top-level `const` initialized with a + * `codeAnchor(…)`/`specTest(…)` call bound to the protocol import. The decorator and JSDoc forms + * stay unextracted Representations. + * + * An anchor is almost all envelope: `id` and the `satisfies`/`verifies` target are binding + * identity (hard errors when non-static or grammar-failing); only `label` is degradable detail. + */ + +const ANCHOR_BUILDER_TARGET_FIELDS = { + codeAnchor: "satisfies", + specTest: "verifies", +} as const; + +type AnchorBuilderName = keyof typeof ANCHOR_BUILDER_TARGET_FIELDS; + +const ANCHOR_ID_NAMESPACES: Record = { + codeAnchor: CODE_ANCHOR_NAMESPACES, + specTest: ["test"], +}; + +/** Every protocol authoring builder, for the misplaced-call scan (§1.3 of the Slice-2 plan). */ +const AUTHORING_BUILDER_NAMES = new Set(["spec", "pack", "codeAnchor", "specTest"]); + +export interface ReifiedAnchor { + /** Plain `CodeAnchor`/`SpecTestAnchor`-shaped data — built from the AST, never evaluated. */ + readonly data: Record; + readonly id: string; + readonly flavor: "code" | "test"; + readonly file: string; + readonly line: number; +} + +export interface AnchorFileReification { + readonly anchors: readonly ReifiedAnchor[]; + readonly findings: readonly Finding[]; +} + +function createAnchorFinding( + validatorId: string, + severity: Severity, + message: string, + file: string, + line: number, + subjectId?: string, + path?: string, +): Finding { + return { + validatorId, + family: "conformance", + severity, + // Location lives in the structured `file`/`line` fields only; renderers print it (one + // diagnostic rendering rule — same as `createExtractFinding`). + message, + subjectId, + path, + file, + line, + }; +} + +function isAnchorBuilderName(builder: string): builder is AnchorBuilderName { + return builder in ANCHOR_BUILDER_TARGET_FIELDS; +} + +function appendAnchorIdFinding( + failure: Exclude, + file: string, + subjectId: string | undefined, + path: string, + findings: Finding[], +): void { + findings.push( + createAnchorFinding( + failure.kind === "non-static" + ? extractFindingIds.nonStaticEnvelope + : extractFindingIds.invalidId, + "error", + `anchor field "${path}" did not reify: ${failure.reason}`, + file, + failure.line, + subjectId, + path, + ), + ); +} + +function reifyAnchorCall( + call: CallExpression, + builder: AnchorBuilderName, + file: string, + bindings: ProtocolBindings, + findings: Finding[], +): ReifiedAnchor | undefined { + const callArguments = call.getArguments(); + const [firstArgument] = callArguments; + let objectLiteral: ObjectLiteralExpression | undefined; + + if (callArguments.length === 1 && firstArgument !== undefined) { + const unwrapped = unwrapTransparent(firstArgument); + + if (Node.isObjectLiteralExpression(unwrapped)) { + objectLiteral = unwrapped; + } + } + + if (objectLiteral === undefined) { + findings.push( + createAnchorFinding( + extractFindingIds.nonStaticEnvelope, + "error", + `${builder}(…) must take exactly one fresh object literal argument`, + file, + call.getStartLineNumber(), + ), + ); + + return undefined; + } + + const targetField = ANCHOR_BUILDER_TARGET_FIELDS[builder]; + const subjectId = peekId(objectLiteral, ANCHOR_ID_NAMESPACES[builder], bindings); + const data: Record = {}; + const authoredNames = new Set(); + let sawOpaqueEntry = false; + let envelopeOk = true; + + const failEnvelope = (line: number, message: string, path?: string): void => { + findings.push( + createAnchorFinding( + extractFindingIds.nonStaticEnvelope, + "error", + message, + file, + line, + subjectId, + path, + ), + ); + envelopeOk = false; + }; + + for (const property of objectLiteral.getProperties()) { + if (!Node.isPropertyAssignment(property)) { + // The absence pass must not call an authored field missing (a non-static field is not an + // absent one): a shorthand entry still names its field; a spread or accessor is opaque. + if (Node.isShorthandPropertyAssignment(property)) { + authoredNames.add(property.getName()); + } else { + sawOpaqueEntry = true; + } + + failEnvelope( + property.getStartLineNumber(), + "the anchor object literal must be fresh: only plain property assignments are static (a spread or shorthand entry could carry binding fields opaquely)", + ); + continue; + } + + const name = readPropertyName(property); + + if (name === undefined) { + sawOpaqueEntry = true; + failEnvelope(property.getStartLineNumber(), "computed property names are non-static"); + continue; + } + + if (authoredNames.has(name)) { + failEnvelope(property.getStartLineNumber(), duplicatePropertyMessage(name), name); + continue; + } + + authoredNames.add(name); + const initializer = property.getInitializer(); + + if (initializer === undefined) { + failEnvelope( + property.getStartLineNumber(), + `property "${name}" carries no initializer`, + name, + ); + continue; + } + + if (name === "id") { + const idResult = reifyStaticIdExpression( + initializer, + ANCHOR_ID_NAMESPACES[builder], + bindings, + "id", + ); + + if (!idResult.ok) { + appendAnchorIdFinding(idResult, file, subjectId, "id", findings); + envelopeOk = false; + continue; + } + + data.id = idResult.id; + continue; + } + + if (name === targetField) { + const idResult = reifyStaticIdExpression(initializer, ["spec"], bindings, targetField); + + if (!idResult.ok) { + appendAnchorIdFinding(idResult, file, subjectId, targetField, findings); + envelopeOk = false; + continue; + } + + data[targetField] = idResult.id; + continue; + } + + if (name === "label") { + const result = reifyStaticString(initializer, "label"); + + if (!result.ok) { + findings.push( + createAnchorFinding( + extractFindingIds.nonStaticSection, + "warning", + `property "label" dropped: at "${result.failure.path}", ${result.failure.reason}`, + file, + result.failure.line, + subjectId, + "label", + ), + ); + continue; + } + + data.label = result.value; + continue; + } + + // An anchor asserts a binding only — never system-truth content (R1). The typed anchor cannot + // carry a foreign field, so a smuggled one (readiness, a delivery fact, acceptance criteria) + // is an envelope error, not droppable detail — the extraction-layer twin of authoring-shape + // honesty, on the anchored surface. + failEnvelope( + property.getStartLineNumber(), + `anchor field "${name}" is outside the binding contract (id · ${targetField} · label) — an anchor asserts a binding only, never system-truth content`, + name, + ); + } + + // Absence is judged on authored names, never on reified values (see `reifySpecCall`). + for (const required of ["id", targetField]) { + if (!authoredNames.has(required) && !sawOpaqueEntry) { + failEnvelope( + call.getStartLineNumber(), + `anchor field "${required}" is missing — the binding cannot be constructed without it`, + required, + ); + } + } + + if (!envelopeOk) { + return undefined; + } + + return { + data, + id: data.id as string, + flavor: builder === "codeAnchor" ? "code" : "test", + file, + line: call.getStartLineNumber(), + }; +} + +/** + * Reifies the anchor constants of one source file standalone — no type checker, no import + * following (static reification without execution, MD-14). Also runs the misplaced-authoring scan: + * a protocol authoring call outside its recognized surface warns loudly (L2 — a binding the + * author believes exists must never silently fall out of the graph) and is not extracted. The + * scan reaches exactly as far as the import-binding contract (`PROTOCOL_MODULE_SPECIFIER`): + * a call through an out-of-contract binding (`require`, a re-aliased local, an element access) + * is indistinguishable from any other library's call without evaluating, so it stays silent — + * the named boundary of the L2 claim, not an oversight. + */ +export function reifyAnchorSourceFile( + sourceFile: SourceFile, + relativePath: string, +): AnchorFileReification { + const bindings = collectProtocolBindings(sourceFile); + + if (bindings.named.size === 0 && bindings.namespaceLocals.size === 0) { + return { anchors: [], findings: [] }; + } + + const anchors: ReifiedAnchor[] = []; + const findings: Finding[] = []; + const recognizedCalls = new Set(); + + for (const statement of sourceFile.getStatements()) { + if (!Node.isVariableStatement(statement)) { + continue; + } + + if (statement.getDeclarationKind() !== VariableDeclarationKind.Const) { + continue; + } + + for (const declaration of statement.getDeclarations()) { + const initializer = declaration.getInitializer(); + const builderCall = + initializer === undefined ? undefined : resolveBuilderCall(initializer, bindings); + + if (builderCall === undefined || !isAnchorBuilderName(builderCall.builder)) { + continue; + } + + recognizedCalls.add(builderCall.call); + const reified = reifyAnchorCall( + builderCall.call, + builderCall.builder, + relativePath, + bindings, + findings, + ); + + if (reified !== undefined) { + anchors.push(reified); + } + } + } + + sourceFile.forEachDescendant((node) => { + if (!Node.isCallExpression(node)) { + return; + } + + const builder = resolveProtocolCalleeBuilder(node.getExpression(), bindings); + + if (builder === undefined || !AUTHORING_BUILDER_NAMES.has(builder)) { + return; + } + + if (recognizedCalls.has(node)) { + return; + } + + const surface = isAnchorBuilderName(builder) + ? "an anchor binds through a top-level const declaration (the anchor-constant form)" + : "spec(…)/pack(…) calls are extracted from *.sdp.ts files only (the .sdp.ts extension, MD-15)"; + + findings.push( + createAnchorFinding( + extractFindingIds.misplacedAuthoring, + "warning", + `"${builder}(…)" call is outside its recognized authoring surface and is not extracted — ${surface}`, + relativePath, + node.getStartLineNumber(), + ), + ); + }); + + return { anchors, findings }; +} diff --git a/src/extract/derive.ts b/src/extract/derive.ts new file mode 100644 index 0000000..d35368a --- /dev/null +++ b/src/extract/derive.ts @@ -0,0 +1,178 @@ +import { computeDeliveryFacts } from "../graph/delivery-facts.js"; +import { schemaVersion } from "../graph/schema.js"; +import type { + AnchorNode, + CodeNode, + GraphEdge, + GraphEdgeType, + GraphNode, + GraphSchema, + PackNode, + PrimitiveNode, +} from "../graph/schema.js"; +import type { SpecAltitude, SpecKind, SpecReadiness } from "../model/descriptors.js"; +import { SPEC_SECTION_NAMES } from "../model/sections.js"; +import type { SpecSections } from "../model/sections.js"; +import type { ReifiedAnchor } from "./anchors.js"; +import type { ReifiedPack, ReifiedSpec } from "./reify.js"; + +interface ReifiedRelation { + readonly type: GraphEdgeType; + readonly target: string; +} + +function pickSections(data: Record): SpecSections | undefined { + const sectionNames = new Set(SPEC_SECTION_NAMES); + const sections: Record = {}; + let present = false; + + for (const key of Object.keys(data)) { + if (sectionNames.has(key)) { + sections[key] = data[key]; + present = true; + } + } + + return present ? sections : undefined; +} + +function derivePrimitiveNode(entry: ReifiedSpec): PrimitiveNode { + const title = entry.data.title; + const sections = pickSections(entry.data); + + return { + id: entry.id, + nodeType: "Primitive", + claim: "declared", + specKind: entry.data.kind as SpecKind, + altitude: entry.data.altitude as SpecAltitude, + readiness: entry.data.readiness as SpecReadiness, + ...(typeof title === "string" ? { title } : {}), + file: entry.file, + ...(sections === undefined ? {} : { sections }), + }; +} + +function derivePackNode(entry: ReifiedPack): PackNode { + const title = entry.data.title; + const framing = entry.data.framing; + const modelRefs = entry.data.modelRefs; + + return { + id: entry.id, + nodeType: "Pack", + claim: "declared", + ...(typeof title === "string" ? { title } : {}), + ...(typeof framing === "string" ? { framing } : {}), + ...(Array.isArray(modelRefs) ? { modelRefs: modelRefs as readonly string[] } : {}), + file: entry.file, + }; +} + +function deriveAnchorNode(entry: ReifiedAnchor): AnchorNode | CodeNode { + const label = entry.data.label; + + if (entry.flavor === "code") { + return { + id: entry.id, + nodeType: "CodeNode", + claim: "anchored", + ...(typeof label === "string" ? { label } : {}), + file: entry.file, + line: entry.line, + }; + } + + return { + id: entry.id, + nodeType: "Anchor", + claim: "anchored", + ...(typeof label === "string" ? { label } : {}), + file: entry.file, + line: entry.line, + }; +} + +/** + * The declared + anchored layers of the one graph: one `Primitive` node per spec, one `Pack` node + * per pack, one binding node per anchor (`CodeNode` for a code anchor, `Anchor` for a test anchor + * — the `03` §1 edge contract), one edge per authored relation, one derived `belongsTo` per + * manifest entry, and one anchored `satisfies`/`verifies` edge per anchor. `belongsTo` is a + * deterministic re-expression of the declared manifest, so it inherits its source's claim — there + * is no 4th claim (`03` §3). A dangling target is emitted, not dropped: the unresolved id itself + * is the sentinel the referential-integrity check (`validateGraph`) flags — but resolution does + * gate the delivery facts (see `computeDeliveryFacts`). Zero `inferred` claims by decision: the + * consumers (the reader's entry adapters and file-level impact) resolve off the curated layers + * (`06` §2), so the first inferred producer is the aspirational impact graph. + */ +export function deriveGraph( + specs: readonly ReifiedSpec[], + packs: readonly ReifiedPack[], + anchors: readonly ReifiedAnchor[], +): GraphSchema { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + + for (const entry of specs) { + nodes.push(derivePrimitiveNode(entry)); + + const relations = Array.isArray(entry.data.relations) + ? (entry.data.relations as readonly ReifiedRelation[]) + : []; + + for (const relation of relations) { + edges.push({ + from: entry.id, + type: relation.type, + to: relation.target, + claim: "declared", + }); + } + } + + for (const entry of packs) { + nodes.push(derivePackNode(entry)); + + const memberIds = Array.isArray(entry.data.specs) + ? (entry.data.specs as readonly string[]) + : []; + + for (const memberId of memberIds) { + edges.push({ + from: memberId, + type: "belongsTo", + to: entry.id, + claim: "declared", + }); + } + } + + for (const entry of anchors) { + nodes.push(deriveAnchorNode(entry)); + + const targetField = entry.flavor === "code" ? "satisfies" : "verifies"; + const target = entry.data[targetField]; + + if (typeof target === "string") { + edges.push({ + from: entry.id, + type: targetField, + to: target, + claim: "anchored", + }); + } + } + + const deliveryFacts = computeDeliveryFacts(nodes, edges); + const decoratedNodes = nodes.map((node) => { + if (node.nodeType !== "Primitive") { + return node; + } + + const facts = deliveryFacts.get(node.id); + + return facts === undefined ? node : { ...node, deliveryFacts: facts }; + }); + + return { schemaVersion, nodes: decoratedNodes, edges }; +} diff --git a/src/extract/discover.ts b/src/extract/discover.ts new file mode 100644 index 0000000..666b068 --- /dev/null +++ b/src/extract/discover.ts @@ -0,0 +1,113 @@ +import { readdirSync } from "node:fs"; +import { join } from "node:path"; + +/** The `.sdp.ts` extension (MD-15): discovery reads spec files and pack manifests by suffix alone. */ +const SPEC_FILE_SUFFIX = ".sdp.ts"; + +/** + * Anchor-candidate source files: the anchored layer lives in real product code (`04` §2), so any + * TypeScript source under the root may carry an anchor constant. Spec files are the declared + * surface (never an anchor home), and declaration files are not source. + */ +const SOURCE_FILE_SUFFIXES = [".ts", ".tsx"] as const; +const DECLARATION_FILE_SUFFIX = ".d.ts"; + +/** + * The tooling-output names discovery skips (alongside every dot-directory). Other build outputs + * (`build/`, `out/`) stay in scope under suffix-alone discovery (MD-15): a stale copy beside its + * live source fails loudly as a duplicate id; widening the skip list is deferred until external + * adoption needs a configurable exclude. + */ +const EXCLUDED_DIRECTORY_NAMES = new Set(["node_modules", "dist", "generated", "coverage"]); + +export interface DiscoveredSourceFile { + readonly absolutePath: string; + /** Extraction-root-relative, POSIX separators, no leading `./` (JS-C3). */ + readonly relativePath: string; +} + +export interface DiscoveredFiles { + readonly specFiles: readonly DiscoveredSourceFile[]; + readonly anchorCandidateFiles: readonly DiscoveredSourceFile[]; +} + +function compareCodeUnits(a: string, b: string): number { + if (a < b) { + return -1; + } + + return a > b ? 1 : 0; +} + +function byRelativePath(left: DiscoveredSourceFile, right: DiscoveredSourceFile): number { + return compareCodeUnits(left.relativePath, right.relativePath); +} + +function isSourceFileName(name: string): boolean { + return ( + SOURCE_FILE_SUFFIXES.some((suffix) => name.endsWith(suffix)) && + !name.endsWith(DECLARATION_FILE_SUFFIX) + ); +} + +function walkDirectory( + absoluteDirectory: string, + relativeDirectory: string, + specFiles: DiscoveredSourceFile[], + anchorCandidateFiles: DiscoveredSourceFile[], +): void { + for (const entry of readdirSync(absoluteDirectory, { withFileTypes: true })) { + const relativePath = + relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`; + + if (entry.isDirectory()) { + // No authoring surface lives in a dot-directory: a stray source copy under one (`.git`, an + // editor history cache) would reify into phantom carriers or duplicate-id hard errors. + if (entry.name.startsWith(".") || EXCLUDED_DIRECTORY_NAMES.has(entry.name)) { + continue; + } + + walkDirectory( + join(absoluteDirectory, entry.name), + relativePath, + specFiles, + anchorCandidateFiles, + ); + continue; + } + + if (!entry.isFile()) { + continue; + } + + if (entry.name.endsWith(SPEC_FILE_SUFFIX)) { + specFiles.push({ absolutePath: join(absoluteDirectory, entry.name), relativePath }); + continue; + } + + if (isSourceFileName(entry.name)) { + anchorCandidateFiles.push({ + absolutePath: join(absoluteDirectory, entry.name), + relativePath, + }); + } + } +} + +/** + * One walk, two surfaces: every `*.sdp.ts` under the extraction root (the declared layer) and + * every other `*.ts`/`*.tsx` source file (the anchor candidates), minus tooling-output + * directories and dot-directories. Both lists are sorted (code-unit, on the root-relative path) + * so diagnostics never depend on filesystem enumeration order; output-byte ordering is owned by + * the serializer regardless. + */ +export function discoverFiles(root: string): DiscoveredFiles { + const specFiles: DiscoveredSourceFile[] = []; + const anchorCandidateFiles: DiscoveredSourceFile[] = []; + walkDirectory(root, "", specFiles, anchorCandidateFiles); + + return { + specFiles: specFiles.sort(byRelativePath), + anchorCandidateFiles: anchorCandidateFiles.sort(byRelativePath), + }; +} diff --git a/src/extract/index.ts b/src/extract/index.ts new file mode 100644 index 0000000..2013e6c --- /dev/null +++ b/src/extract/index.ts @@ -0,0 +1,225 @@ +import { readFileSync } from "node:fs"; + +import { Project } from "ts-morph"; +import type { Program, SourceFile } from "ts-morph"; + +import type { GraphSchema } from "../graph/schema.js"; +import type { Finding, ValidationReport } from "../validate/contracts.js"; +import { reifyAnchorSourceFile } from "./anchors.js"; +import type { ReifiedAnchor } from "./anchors.js"; +import { deriveGraph } from "./derive.js"; +import { discoverFiles } from "./discover.js"; +import type { DiscoveredSourceFile } from "./discover.js"; +import { PROTOCOL_MODULE_SPECIFIER, extractFindingIds, reifySourceFile } from "./reify.js"; +import type { ReifiedPack, ReifiedSpec } from "./reify.js"; + +export { PROTOCOL_MODULE_SPECIFIER, extractFindingIds } from "./reify.js"; +export { serializeGraph } from "./serialize.js"; + +export const extractValidatorId = "extract"; + +export interface ExtractOptions { + /** + * The extraction root: every `*.sdp.ts` below it (minus tooling output) is read as the declared + * layer, and every other `*.ts`/`*.tsx` source file is swept for anchor constants (the anchored + * layer). + */ + readonly root: string; +} + +export interface ExtractionCounts { + readonly specs: number; + readonly packs: number; + readonly anchors: number; +} + +export interface ExtractionResult { + readonly graph: GraphSchema; + /** + * The full extraction report — the existing `ValidationReport`/`Finding` currency, no parallel + * shape. Extraction always completes and reports every finding (L3); with any hard error present + * the emitted artifact is withheld by `sdp build`, but programmatic callers still get the + * partial in-memory graph plus this report. + */ + readonly report: ValidationReport; + /** + * Authored-carrier counts as reified — duplicate-id carriers included: the truthful record of + * what was authored even when ambiguity (L2) excludes a carrier from the graph, which cannot be + * keyed on an ambiguous id. Everything else about the authored layer is read off the graph + * itself (one validation path, MD-14). + */ + readonly counts: ExtractionCounts; +} + +function compareCodeUnits(left: string, right: string): number { + if (left < right) { + return -1; + } + + return left > right ? 1 : 0; +} + +function sortFindings(findings: readonly Finding[]): readonly Finding[] { + return [...findings].sort( + (left, right) => + compareCodeUnits(left.file ?? "", right.file ?? "") || + (left.line ?? 0) - (right.line ?? 0) || + compareCodeUnits(left.validatorId, right.validatorId), + ); +} + +function findDuplicatedIds( + specs: readonly ReifiedSpec[], + packs: readonly ReifiedPack[], + anchors: readonly ReifiedAnchor[], + findings: Finding[], +): ReadonlySet { + const sites = new Map(); + + for (const entry of [...specs, ...packs, ...anchors]) { + const list = sites.get(entry.id) ?? []; + list.push({ file: entry.file, line: entry.line }); + sites.set(entry.id, list); + } + + const duplicated = new Set(); + + for (const [id, locations] of sites) { + if (locations.length < 2) { + continue; + } + + duplicated.add(id); + + for (const location of locations) { + findings.push({ + validatorId: extractFindingIds.duplicateId, + family: "conformance", + severity: "error", + message: `id "${id}" is reified from ${String(locations.length)} sites (ambiguity is loud, L2); every site is reported and none enters the graph`, + subjectId: id, + file: location.file, + line: location.line, + }); + } + } + + return duplicated; +} + +interface ParsedSourceFile { + readonly file: DiscoveredSourceFile; + readonly sourceFile: SourceFile; +} + +/** + * A file carrying a syntactic diagnostic is excluded whole: the parser is error-tolerant, so past + * the first syntax error the recovered AST is guesswork — content bleeds between carriers — and + * reifying it would put unfaithful shapes in the graph, or silently drop a binding the author + * believes exists (L2). One hard error per file, carrying the first diagnostic; every other file + * still extracts (graceful partial extraction, L3, at file granularity). + */ +function fileParses(program: Program, source: ParsedSourceFile, findings: Finding[]): boolean { + const [firstDiagnostic] = program.getSyntacticDiagnostics(source.sourceFile); + + if (firstDiagnostic === undefined) { + return true; + } + + const text = firstDiagnostic.getMessageText(); + + findings.push({ + validatorId: extractFindingIds.parseError, + family: "conformance", + severity: "error", + message: `the file does not parse: ${typeof text === "string" ? text : text.getMessageText()} — the error-tolerant parse recovers by guessing, so the file cannot be reified faithfully and its content is excluded from the graph (ambiguity is loud, L2)`, + file: source.file.relativePath, + line: firstDiagnostic.getLineNumber(), + }); + + return false; +} + +/** + * The extractor — the producer, the only component that reads source (`03` §1): the declared + * layer (spec files, pack manifests) plus the anchored layer (anchor constants in source files). + * Files are reified standalone by pure AST reading (no type checker, no tsconfig dependence, no + * import following — static reification without execution, MD-14); a file that does not parse is + * excluded whole, loudly (`fileParses`). Then the one graph is derived, delivery facts included. + * The conformance + honesty checks consume the graph (`validateGraph`), never any pre-graph + * shape. The inferred layer is empty by decision, not omission: its consumers (the reader's entry + * adapters and file-level impact) resolve off the curated layers (`06` §2), so the first inferred + * producer is the aspirational impact graph. + */ +export function extract(options: ExtractOptions): ExtractionResult { + const files = discoverFiles(options.root); + // The project only ever parses: reification is pure AST reading and the program exists solely + // for syntactic diagnostics, so the default lib would never be read — `noLib` skips loading it. + const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { noLib: true } }); + const specs: ReifiedSpec[] = []; + const packs: ReifiedPack[] = []; + const anchors: ReifiedAnchor[] = []; + const findings: Finding[] = []; + const specSources: ParsedSourceFile[] = []; + const anchorSources: ParsedSourceFile[] = []; + + for (const file of files.specFiles) { + const sourceText = readFileSync(file.absolutePath, "utf8"); + specSources.push({ file, sourceFile: project.createSourceFile(file.relativePath, sourceText) }); + } + + for (const file of files.anchorCandidateFiles) { + const sourceText = readFileSync(file.absolutePath, "utf8"); + + // Anchors are recognized by import binding, and the contract takes the specifier written + // verbatim — so a raw text test skips the AST work for the bulk of source files. An + // escape-spelled specifier (same cooked value, different raw text) sits outside the binding + // contract here, exactly as `require` and re-aliased locals do. + if (!sourceText.includes(PROTOCOL_MODULE_SPECIFIER)) { + continue; + } + + anchorSources.push({ + file, + sourceFile: project.createSourceFile(file.relativePath, sourceText), + }); + } + + // One program, requested after every file is added: ts-morph rebuilds the program whenever a + // file lands, so asking per file would redo the work quadratically. + const program = project.getProgram(); + + for (const source of specSources) { + if (!fileParses(program, source, findings)) { + continue; + } + + const reified = reifySourceFile(source.sourceFile, source.file.relativePath); + specs.push(...reified.specs); + packs.push(...reified.packs); + findings.push(...reified.findings); + } + + for (const source of anchorSources) { + if (!fileParses(program, source, findings)) { + continue; + } + + const reified = reifyAnchorSourceFile(source.sourceFile, source.file.relativePath); + anchors.push(...reified.anchors); + findings.push(...reified.findings); + } + + const duplicated = findDuplicatedIds(specs, packs, anchors, findings); + const graph = deriveGraph( + specs.filter((entry) => !duplicated.has(entry.id)), + packs.filter((entry) => !duplicated.has(entry.id)), + anchors.filter((entry) => !duplicated.has(entry.id)), + ); + + return { + graph, + report: { validatorId: extractValidatorId, findings: sortFindings(findings) }, + counts: { specs: specs.length, packs: packs.length, anchors: anchors.length }, + }; +} diff --git a/src/extract/reify.ts b/src/extract/reify.ts new file mode 100644 index 0000000..1babf7a --- /dev/null +++ b/src/extract/reify.ts @@ -0,0 +1,1507 @@ +import { Node, SyntaxKind, VariableDeclarationKind } from "ts-morph"; +import type { + ArrayLiteralExpression, + CallExpression, + ObjectLiteralExpression, + PropertyAssignment, + SourceFile, +} from "ts-morph"; + +import { deliveryFactNames } from "../graph/schema.js"; +import { CODE_ANCHOR_NAMESPACES, parseId } from "../ids.js"; +import { SPEC_ALTITUDES, SPEC_KINDS, SPEC_READINESS } from "../model/descriptors.js"; +import { SPEC_RELATION_TYPES } from "../model/relations.js"; +import { SPEC_SECTION_NAMES } from "../model/sections.js"; +import type { Finding, Severity } from "../validate/contracts.js"; + +/** + * Static reification (`04` §1): a spec file is a JSON file that TypeScript happens to validate + * (P5), so reification reads the AST and never evaluates — no imports are followed, no builder is + * called (evaluation is the phantom-value trap MD-14 closes). Recognized builders are matched by + * import binding from this one module specifier — named imports (authored aliasing survives), + * namespace imports (`ns.builder(…)`), and a default-import local treated the same (the package + * ships no default export, but an interop consumer can author through one) — so lookalike + * builders from other modules stay non-static. The boundary is the import declaration: a binding + * reached any other way (`require`, a re-aliased local, an element access) is out of contract and + * stays out of the graph. + */ +export const PROTOCOL_MODULE_SPECIFIER = "@libar-dev/software-delivery-protocol"; + +/** + * The extraction finding ids, pinned. Two tiers (`03` §2), covering both authored surfaces (spec + * files and anchor constants): envelope failures (`non-static-envelope` · `invalid-id` · + * `duplicate-id` · `reserved-property`) are hard errors — the carrier is not extracted and the + * build fails; content failures (`non-static-section` · `unrecognized-statement` · + * `unrecognized-property` · `misplaced-authoring`) degrade loudly — one property, statement, or + * call is dropped and the rest survives (graceful partial extraction, L3). The content-tier ids + * name the *tier*, not the artifact: `non-static-envelope` is the general envelope-failure id — + * a non-static or opaque entry, a required field missing, a property name authored twice — + * everything that leaves the carrier unconstructable; `non-static-section` also covers an + * anchor's degradable `label`, `unrecognized-property` covers a spec or pack property outside + * its authored shape (a typoed section name must never silently fall out of the graph, L2), and + * `misplaced-authoring` + * covers any protocol authoring call outside its recognized surface (an anchor builder not in + * top-level-const position; a `spec(…)`/`pack(…)` call in a non-`.sdp.ts` file) — a binding the + * author believes exists must never silently fall out of the graph (L2). `reserved-property` is + * the envelope-tier honesty twin: a hand-authored piece of derived graph vocabulary (a delivery + * fact, a `claim`, an edge field) impersonates machine truth, so the carrier is rejected whole. + * Above both tiers sits the one file-level id: `parse-error` — a file carrying a syntactic + * diagnostic is never reified, because the error-tolerant parse recovers by guessing and content + * bleeds between carriers; one hard error per file, carrying the first diagnostic, and the whole + * file's content stays out of the graph (ambiguity is loud, L2). + */ +export const extractFindingIds = { + parseError: "extract/parse-error", + nonStaticEnvelope: "extract/non-static-envelope", + invalidId: "extract/invalid-id", + duplicateId: "extract/duplicate-id", + reservedProperty: "extract/reserved-property", + nonStaticSection: "extract/non-static-section", + unrecognizedStatement: "extract/unrecognized-statement", + unrecognizedProperty: "extract/unrecognized-property", + misplacedAuthoring: "extract/misplaced-authoring", +} as const; + +/** + * Builders whose single string-literal argument reifies to an id, mapped to the namespaces that + * builder accepts (its own runtime contract — the extractor never evaluates, so it re-states the + * check statically). + */ +export const ID_UNWRAP_BUILDERS: ReadonlyMap = new Map< + string, + readonly string[] +>([ + ["specId", ["spec"]], + ["packId", ["pack"]], + ["ref", ["spec"]], + ["codeAnchorId", CODE_ANCHOR_NAMESPACES], + ["testAnchorId", ["test"]], +]); + +const RELATION_BUILDER_NAMES = new Set(SPEC_RELATION_TYPES); +const SPEC_KIND_VALUES = new Set(SPEC_KINDS); +const SPEC_ALTITUDE_VALUES = new Set(SPEC_ALTITUDES); +const SPEC_READINESS_VALUES = new Set(SPEC_READINESS); +const SPEC_SECTION_NAME_SET = new Set(SPEC_SECTION_NAMES); + +/** + * Derived graph vocabulary an authored carrier must never state: the delivery facts plus the + * graph's own node and edge fields. Hand-authoring one impersonates machine truth, so it is an + * envelope-tier hard error — the extraction-layer twin of authoring-shape honesty (`05` §2, + * check 5) on the top-level authored shape, exactly as raw `relations[]` entries and foreign + * anchor fields are on theirs. In-section content stays the honesty checks' jurisdiction: it + * reifies through and the `honesty/authoring-shape` validator sees it in the model. + */ +const RESERVED_DERIVED_PROPERTIES = new Set([ + ...deliveryFactNames, + "deliveryFacts", + "claim", + "nodeType", + "specKind", + "satisfies", + "verifies", + "belongsTo", +]); + +export interface ReifiedSpec { + /** Plain `Spec`-shaped data in authored property order — built from the AST, never evaluated. */ + readonly data: Record; + readonly id: string; + readonly file: string; + readonly line: number; +} + +export interface ReifiedPack { + readonly data: Record; + readonly id: string; + readonly file: string; + readonly line: number; +} + +export interface FileReification { + readonly specs: readonly ReifiedSpec[]; + readonly packs: readonly ReifiedPack[]; + readonly findings: readonly Finding[]; +} + +function createExtractFinding( + validatorId: string, + severity: Severity, + message: string, + file: string, + line: number, + subjectId?: string, + path?: string, +): Finding { + return { + validatorId, + family: "conformance", + severity, + // Location lives in the structured `file`/`line` fields only; renderers print it. Embedding + // it in the message too would state the same information twice (one diagnostic rendering + // rule). + message, + subjectId, + path, + file, + line, + }; +} + +/* ----- the static value grammar ----- */ + +interface StaticFailure { + readonly path: string; + readonly line: number; + readonly reason: string; +} + +export type StaticResult = + | { readonly ok: true; readonly value: unknown } + | { readonly ok: false; readonly failure: StaticFailure }; + +function staticFailure(node: Node, path: string, reason: string): StaticResult { + return { ok: false, failure: { path, line: node.getStartLineNumber(), reason } }; +} + +function describeNonStatic(node: Node): string { + return `${node.getKindName()} is outside the static value grammar (string/number/boolean literals, array and fresh object literals; \`as const\` and parentheses are transparent; id builders unwrap in id slots only)`; +} + +/** `as const` and parentheses are transparent; every other wrapper is non-static. */ +export function unwrapTransparent(node: Node): Node { + let current = node; + + for (;;) { + if (Node.isParenthesizedExpression(current)) { + current = current.getExpression(); + continue; + } + + if (Node.isAsExpression(current) && current.getTypeNode()?.getText() === "const") { + current = current.getExpression(); + continue; + } + + return current; + } +} + +export interface ProtocolBindings { + /** Local name → exported builder name, from named imports. */ + readonly named: ReadonlyMap; + /** + * Locals bound by `import * as ns` — every protocol builder is reachable as a property — and by + * a default import (the package ships no default export, but an interop consumer can still + * author through one; treating it the same keeps the binding from silently falling out, L2). + */ + readonly namespaceLocals: ReadonlySet; +} + +export function collectProtocolBindings(sourceFile: SourceFile): ProtocolBindings { + const named = new Map(); + const namespaceLocals = new Set(); + + for (const importDeclaration of sourceFile.getImportDeclarations()) { + if (importDeclaration.getModuleSpecifierValue() !== PROTOCOL_MODULE_SPECIFIER) { + continue; + } + + for (const namedImport of importDeclaration.getNamedImports()) { + const exportedName = namedImport.getName(); + const localName = namedImport.getAliasNode()?.getText() ?? exportedName; + named.set(localName, exportedName); + } + + const namespaceImport = importDeclaration.getNamespaceImport(); + + if (namespaceImport !== undefined) { + namespaceLocals.add(namespaceImport.getText()); + } + + const defaultImport = importDeclaration.getDefaultImport(); + + if (defaultImport !== undefined) { + namespaceLocals.add(defaultImport.getText()); + } + } + + return { named, namespaceLocals }; +} + +export interface ResolvedBuilderCall { + readonly call: CallExpression; + readonly builder: string; +} + +/** + * A binding identifier matched by text can be lexically shadowed — a parameter or local sharing + * the import's name is somebody else's value, and attributing its calls to the protocol would + * raise spurious findings. The walk is syntactic (no type checker, MD-14): parameters, variable + * declarations, function/class declaration names, and catch bindings on the path to the file top. + */ +function isShadowedAtUse(use: Node, name: string): boolean { + for (let scope = use.getParent(); scope !== undefined; scope = scope.getParent()) { + if ( + (Node.isFunctionDeclaration(scope) || + Node.isFunctionExpression(scope) || + Node.isArrowFunction(scope) || + Node.isMethodDeclaration(scope) || + Node.isConstructorDeclaration(scope) || + Node.isGetAccessorDeclaration(scope) || + Node.isSetAccessorDeclaration(scope)) && + scope.getParameters().some((parameter) => parameter.getName() === name) + ) { + return true; + } + + if (Node.isCatchClause(scope) && scope.getVariableDeclaration()?.getName() === name) { + return true; + } + + if (Node.isBlock(scope) || Node.isModuleBlock(scope)) { + for (const statement of scope.getStatements()) { + if ( + Node.isVariableStatement(statement) && + statement.getDeclarations().some((declaration) => declaration.getName() === name) + ) { + return true; + } + + if ( + (Node.isFunctionDeclaration(statement) || Node.isClassDeclaration(statement)) && + statement.getName() === name + ) { + return true; + } + } + } + } + + return false; +} + +/** + * The callee form mirrors the import form: a bare identifier from a named import, or + * `ns.builder(…)` through a namespace- or default-import local — unless the binding is lexically + * shadowed at the use site. Anything else (an element access, a re-aliased local, a `require` + * binding) is not an import binding of the protocol package, so it stays non-static and out of + * the graph — the recognized-forms boundary the misplaced-authoring sweep polices. + */ +export function resolveProtocolCalleeBuilder( + callee: Node, + bindings: ProtocolBindings, +): string | undefined { + if (Node.isIdentifier(callee)) { + const builder = bindings.named.get(callee.getText()); + + return builder !== undefined && !isShadowedAtUse(callee, callee.getText()) + ? builder + : undefined; + } + + if (Node.isPropertyAccessExpression(callee)) { + const qualifier = callee.getExpression(); + + return Node.isIdentifier(qualifier) && + bindings.namespaceLocals.has(qualifier.getText()) && + !isShadowedAtUse(qualifier, qualifier.getText()) + ? callee.getName() + : undefined; + } + + return undefined; +} + +export function resolveBuilderCall( + node: Node, + bindings: ProtocolBindings, +): ResolvedBuilderCall | undefined { + const unwrapped = unwrapTransparent(node); + + if (!Node.isCallExpression(unwrapped)) { + return undefined; + } + + const builder = resolveProtocolCalleeBuilder(unwrapped.getExpression(), bindings); + + return builder === undefined ? undefined : { call: unwrapped, builder }; +} + +export function readPropertyName(property: PropertyAssignment): string | undefined { + const nameNode = property.getNameNode(); + + if (Node.isIdentifier(nameNode)) { + return nameNode.getText(); + } + + if (Node.isStringLiteral(nameNode)) { + return nameNode.getLiteralValue(); + } + + return undefined; +} + +/** + * A property name authored twice at one object tier is ambiguity, never detail: evaluation keeps + * the last value while diagnostics key on the first seen. tsc reports the duplication (TS1117) to + * typechecking authors; the extractor reads files standalone, so it is the backstop — at every + * tier: the envelope fails the carrier whole, the section tier drops the repeat and keeps the + * first authored value (L3). The consequence clause names the caller's tier. + */ +export function duplicatePropertyMessage( + name: string, + consequence = "so the carrier is not extracted", +): string { + return `property "${name}" is authored more than once in this carrier (ambiguity is loud, L2); evaluation would keep the last value silently, ${consequence}`; +} + +export function reifyStaticString(node: Node, path: string): StaticResult { + const unwrapped = unwrapTransparent(node); + + if (Node.isStringLiteral(unwrapped) || Node.isNoSubstitutionTemplateLiteral(unwrapped)) { + return { ok: true, value: unwrapped.getLiteralValue() }; + } + + return staticFailure(unwrapped, path, describeNonStatic(unwrapped)); +} + +export type IdReification = + | { readonly ok: true; readonly id: string } + | { + readonly ok: false; + readonly kind: "non-static" | "invalid"; + readonly line: number; + readonly reason: string; + }; + +function namespacesList(namespaces: readonly string[]): string { + return namespaces.map((entry) => `"${entry}"`).join(" · "); +} + +function namespacesLabel(namespaces: readonly string[]): string { + return namespaces.length === 1 + ? `${namespacesList(namespaces)} is required` + : `one of ${namespacesList(namespaces)} is required`; +} + +/** + * An id slot accepts a string literal or an id-builder unwrap (`specId` / `packId` / `ref` / + * `codeAnchorId` / `testAnchorId` around a string literal); the reified string must clear the + * `parseId` grammar and carry one of the slot's namespaces — and, when a builder wraps it, one of + * that builder's own namespaces (its runtime contract, restated statically) — the graph is never + * keyed on a malformed id or on a carrier evaluation would have rejected. + */ +export function reifyStaticIdExpression( + node: Node, + expectedNamespaces: readonly string[], + bindings: ProtocolBindings, + path: string, +): IdReification { + const unwrapped = unwrapTransparent(node); + const builderCall = resolveBuilderCall(unwrapped, bindings); + const builderNamespaces = + builderCall === undefined ? undefined : ID_UNWRAP_BUILDERS.get(builderCall.builder); + let stringResult: StaticResult; + + if (builderCall !== undefined && builderNamespaces !== undefined) { + const [argument] = builderCall.call.getArguments(); + stringResult = + argument === undefined || builderCall.call.getArguments().length !== 1 + ? staticFailure(builderCall.call, path, "id builder must wrap exactly one string literal") + : reifyStaticString(argument, path); + } else { + stringResult = reifyStaticString(unwrapped, path); + } + + if (!stringResult.ok) { + return { + ok: false, + kind: "non-static", + line: stringResult.failure.line, + reason: stringResult.failure.reason, + }; + } + + const idText = stringResult.value as string; + const line = unwrapped.getStartLineNumber(); + + try { + const parsed = parseId(idText); + + // The wrapping builder's contract checks first: it is the narrower statement, and the one + // evaluation would enforce (the builder throws on a foreign namespace). + if ( + builderCall !== undefined && + builderNamespaces !== undefined && + !builderNamespaces.includes(parsed.namespace) + ) { + return { + ok: false, + kind: "invalid", + line, + reason: `id "${idText}" carries namespace "${parsed.namespace}" where ${builderCall.builder}(…) accepts only ${namespacesList(builderNamespaces)} — the builder's own contract, restated statically`, + }; + } + + if (!expectedNamespaces.includes(parsed.namespace)) { + return { + ok: false, + kind: "invalid", + line, + reason: `id "${idText}" carries namespace "${parsed.namespace}" where ${namespacesLabel(expectedNamespaces)}`, + }; + } + } catch (error) { + return { + ok: false, + kind: "invalid", + line, + reason: error instanceof Error ? error.message : `id "${idText}" fails the id grammar`, + }; + } + + return { ok: true, id: idText }; +} + +function reifyStaticValue(node: Node, path: string, bindings: ProtocolBindings): StaticResult { + const unwrapped = unwrapTransparent(node); + + if (Node.isStringLiteral(unwrapped) || Node.isNoSubstitutionTemplateLiteral(unwrapped)) { + return { ok: true, value: unwrapped.getLiteralValue() }; + } + + if (Node.isNumericLiteral(unwrapped)) { + return { ok: true, value: unwrapped.getLiteralValue() }; + } + + if ( + Node.isPrefixUnaryExpression(unwrapped) && + unwrapped.getOperatorToken() === SyntaxKind.MinusToken + ) { + const operand = unwrapTransparent(unwrapped.getOperand()); + + if (Node.isNumericLiteral(operand)) { + return { ok: true, value: -operand.getLiteralValue() }; + } + + return staticFailure(operand, path, describeNonStatic(operand)); + } + + if (unwrapped.getKind() === SyntaxKind.TrueKeyword) { + return { ok: true, value: true }; + } + + if (unwrapped.getKind() === SyntaxKind.FalseKeyword) { + return { ok: true, value: false }; + } + + if (Node.isArrayLiteralExpression(unwrapped)) { + return reifyStaticArray(unwrapped, path, bindings); + } + + if (Node.isObjectLiteralExpression(unwrapped)) { + return reifyStaticObject(unwrapped, path, bindings); + } + + const builderCall = resolveBuilderCall(unwrapped, bindings); + + if (builderCall !== undefined) { + // Id builders unwrap in id slots only (`reifyStaticIdExpression`), never in a value position: + // a `ref(…)` riding section content would survive as a plain string the graph treats as + // ordinary prose — a smuggled reference no referential check ever sees. Sections carry + // content, relations carry linkage (MD-10). The guard covers the typed affordance only: a + // raw id-shaped string in content is prose by definition — never a reference, never an edge, + // never validated — exactly as any sentence naming a spec is. Closing that would mean + // policing prose, which checks never do (conformance and honesty, never content-quality); + // the boundary is pinned by the `id-shaped-string-content` corpus. + if (ID_UNWRAP_BUILDERS.has(builderCall.builder)) { + return staticFailure( + unwrapped, + path, + `call to "${builderCall.builder}" is an id builder outside an id slot — sections carry content, relations carry linkage (MD-10), so a spec reference cannot ride along as content`, + ); + } + + return staticFailure( + unwrapped, + path, + `call to "${builderCall.builder}" is non-static in a value position`, + ); + } + + return staticFailure(unwrapped, path, describeNonStatic(unwrapped)); +} + +/** Arrays are strict: dropping one element would silently reshape its siblings, so any non-static element fails the whole array value. */ +function reifyStaticArray( + arrayLiteral: ArrayLiteralExpression, + path: string, + bindings: ProtocolBindings, +): StaticResult { + const values: unknown[] = []; + + for (const [index, element] of arrayLiteral.getElements().entries()) { + const elementPath = `${path}[${String(index)}]`; + const result = reifyStaticValue(element, elementPath, bindings); + + if (!result.ok) { + return result; + } + + values.push(result.value); + } + + return { ok: true, value: values }; +} + +function reifyStaticObject( + objectLiteral: ObjectLiteralExpression, + path: string, + bindings: ProtocolBindings, +): StaticResult { + const value: Record = {}; + const seenNames = new Set(); + + for (const property of objectLiteral.getProperties()) { + if (!Node.isPropertyAssignment(property)) { + return staticFailure( + property, + path, + "only plain property assignments are static (no spreads, shorthand, methods, or accessors)", + ); + } + + const name = readPropertyName(property); + + if (name === undefined) { + return staticFailure(property, path, "computed property names are non-static"); + } + + // A repeated name would last-win silently — the carrier-level duplicate guard, kept at every + // object tier (ambiguity is loud, L2). + if (seenNames.has(name)) { + return staticFailure( + property, + `${path}.${name}`, + duplicatePropertyMessage(name, "so the value cannot be reified faithfully"), + ); + } + + seenNames.add(name); + + const initializer = property.getInitializer(); + + if (initializer === undefined) { + return staticFailure(property, path, "property carries no initializer"); + } + + const result = reifyStaticValue(initializer, `${path}.${name}`, bindings); + + if (!result.ok) { + return result; + } + + value[name] = result.value; + } + + return { ok: true, value }; +} + +/* ----- lossy reification: the section tier ----- */ + +interface LossyDrop { + /** The property removed — the drop unit (`03` §2: that one property, the rest survives). */ + readonly droppedPath: string; + /** The deepest failing node, for the message. */ + readonly failurePath: string; + readonly line: number; + readonly reason: string; +} + +interface LossyObjectResult { + readonly value: Record; + readonly drops: readonly LossyDrop[]; +} + +/** + * 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. + */ +function reifyObjectLossy( + objectLiteral: ObjectLiteralExpression, + path: string, + bindings: ProtocolBindings, +): LossyObjectResult { + const value: Record = {}; + const drops: LossyDrop[] = []; + const seenNames = new Set(); + + for (const property of objectLiteral.getProperties()) { + if (!Node.isPropertyAssignment(property)) { + const name = Node.isShorthandPropertyAssignment(property) ? property.getName() : ""; + drops.push({ + droppedPath: `${path}.${name}`, + failurePath: `${path}.${name}`, + line: property.getStartLineNumber(), + reason: + "only plain property assignments are static (no spreads, shorthand, methods, or accessors)", + }); + continue; + } + + const name = readPropertyName(property); + + if (name === undefined) { + drops.push({ + droppedPath: path, + failurePath: path, + line: property.getStartLineNumber(), + reason: "computed property names are non-static", + }); + continue; + } + + const propertyPath = `${path}.${name}`; + + // A repeated name would last-win silently; at the section tier the repeat drops with a + // warning and the first authored value survives (graceful partial extraction, L3). + if (seenNames.has(name)) { + drops.push({ + droppedPath: propertyPath, + failurePath: propertyPath, + line: property.getStartLineNumber(), + reason: duplicatePropertyMessage( + name, + "so the repeat drops and the first authored value survives", + ), + }); + continue; + } + + seenNames.add(name); + const initializer = property.getInitializer(); + + if (initializer === undefined) { + drops.push({ + droppedPath: propertyPath, + failurePath: propertyPath, + line: property.getStartLineNumber(), + reason: "property carries no initializer", + }); + continue; + } + + const inner = unwrapTransparent(initializer); + + if (Node.isObjectLiteralExpression(inner)) { + const nested = reifyObjectLossy(inner, propertyPath, bindings); + value[name] = nested.value; + drops.push(...nested.drops); + continue; + } + + const result = reifyStaticValue(initializer, propertyPath, bindings); + + if (result.ok) { + value[name] = result.value; + continue; + } + + drops.push({ + droppedPath: propertyPath, + failurePath: result.failure.path, + line: result.failure.line, + reason: result.failure.reason, + }); + } + + return { value, drops }; +} + +/* ----- spec() and pack() call reification ----- */ + +interface CallReification { + readonly entry?: TEntry; + readonly findings: readonly Finding[]; +} + +export function peekId( + objectLiteral: ObjectLiteralExpression, + expectedNamespaces: readonly string[], + bindings: ProtocolBindings, +): string | undefined { + for (const property of objectLiteral.getProperties()) { + if (!Node.isPropertyAssignment(property) || readPropertyName(property) !== "id") { + continue; + } + + const initializer = property.getInitializer(); + + if (initializer === undefined) { + return undefined; + } + + const result = reifyStaticIdExpression(initializer, expectedNamespaces, bindings, "id"); + + return result.ok ? result.id : undefined; + } + + return undefined; +} + +function requireSingleObjectArgument( + call: CallExpression, + builderName: string, + file: string, + findings: Finding[], +): ObjectLiteralExpression | undefined { + const callArguments = call.getArguments(); + const [firstArgument] = callArguments; + const unwrapped = + callArguments.length === 1 && firstArgument !== undefined + ? unwrapTransparent(firstArgument) + : undefined; + + if (unwrapped !== undefined && Node.isObjectLiteralExpression(unwrapped)) { + return unwrapped; + } + + findings.push( + createExtractFinding( + extractFindingIds.nonStaticEnvelope, + "error", + `${builderName}(…) must take exactly one fresh object literal argument`, + file, + call.getStartLineNumber(), + ), + ); + + return undefined; +} + +function appendIdFinding( + failure: Exclude, + file: string, + subjectId: string | undefined, + path: string, + findings: Finding[], +): void { + findings.push( + createExtractFinding( + failure.kind === "non-static" + ? extractFindingIds.nonStaticEnvelope + : extractFindingIds.invalidId, + "error", + `envelope field "${path}" did not reify: ${failure.reason}`, + file, + failure.line, + subjectId, + path, + ), + ); +} + +function appendDropFindings( + drops: readonly LossyDrop[], + file: string, + subjectId: string | undefined, + findings: Finding[], +): void { + for (const drop of drops) { + findings.push( + createExtractFinding( + extractFindingIds.nonStaticSection, + "warning", + `property "${drop.droppedPath}" dropped: at "${drop.failurePath}", ${drop.reason}`, + file, + drop.line, + subjectId, + drop.droppedPath, + ), + ); + } +} + +interface ReifiedRelations { + readonly ok: boolean; + readonly relations: readonly Record[]; +} + +/** + * A `relations[]` entry is exactly one of the six relation builders around one static spec id. A + * raw object literal here could smuggle a derived edge (`satisfies`, a foreign `claim`) into the + * authored layer, so anything else is an envelope error — the extraction-layer twin of + * authoring-shape honesty. + */ +function reifyRelations( + node: Node, + file: string, + subjectId: string | undefined, + bindings: ProtocolBindings, + findings: Finding[], +): ReifiedRelations { + const unwrapped = unwrapTransparent(node); + + if (!Node.isArrayLiteralExpression(unwrapped)) { + findings.push( + createExtractFinding( + extractFindingIds.nonStaticEnvelope, + "error", + `envelope field "relations" must be an array literal`, + file, + unwrapped.getStartLineNumber(), + subjectId, + "relations", + ), + ); + + return { ok: false, relations: [] }; + } + + let ok = true; + const relations: Record[] = []; + + for (const [index, element] of unwrapped.getElements().entries()) { + const entryPath = `relations[${String(index)}]`; + const builderCall = resolveBuilderCall(element, bindings); + + if (builderCall === undefined || !RELATION_BUILDER_NAMES.has(builderCall.builder)) { + findings.push( + createExtractFinding( + extractFindingIds.nonStaticEnvelope, + "error", + `"${entryPath}" is not one of the six relation builders (${SPEC_RELATION_TYPES.join(" · ")}) — a raw relation entry can smuggle a derived edge, so it is rejected at the envelope tier`, + file, + element.getStartLineNumber(), + subjectId, + entryPath, + ), + ); + ok = false; + continue; + } + + const builderArguments = builderCall.call.getArguments(); + const [target] = builderArguments; + + if (builderArguments.length !== 1 || target === undefined) { + findings.push( + createExtractFinding( + extractFindingIds.nonStaticEnvelope, + "error", + `"${entryPath}" must wrap exactly one spec id target`, + file, + builderCall.call.getStartLineNumber(), + subjectId, + entryPath, + ), + ); + ok = false; + continue; + } + + const idResult = reifyStaticIdExpression(target, ["spec"], bindings, `${entryPath}.target`); + + if (!idResult.ok) { + appendIdFinding(idResult, file, subjectId, `${entryPath}.target`, findings); + ok = false; + continue; + } + + relations.push({ type: builderCall.builder, target: idResult.id, claim: "declared" }); + } + + return { ok, relations }; +} + +interface EnvelopeEnumField { + readonly name: "kind" | "altitude" | "readiness"; + readonly values: ReadonlySet; + readonly label: string; +} + +const SPEC_ENUM_FIELDS: readonly EnvelopeEnumField[] = [ + { name: "kind", values: SPEC_KIND_VALUES, label: SPEC_KINDS.join(" · ") }, + { name: "altitude", values: SPEC_ALTITUDE_VALUES, label: SPEC_ALTITUDES.join(" · ") }, + { name: "readiness", values: SPEC_READINESS_VALUES, label: SPEC_READINESS.join(" · ") }, +]; + +function reifySpecCall( + call: CallExpression, + file: string, + bindings: ProtocolBindings, +): CallReification { + const findings: Finding[] = []; + const objectLiteral = requireSingleObjectArgument(call, "spec", file, findings); + + if (objectLiteral === undefined) { + return { findings }; + } + + const subjectId = peekId(objectLiteral, ["spec"], bindings); + const data: Record = {}; + const authoredNames = new Set(); + let sawOpaqueEntry = false; + let envelopeOk = true; + + const failEnvelope = (line: number, message: string, path?: string): void => { + findings.push( + createExtractFinding( + extractFindingIds.nonStaticEnvelope, + "error", + message, + file, + line, + subjectId, + path, + ), + ); + envelopeOk = false; + }; + + for (const property of objectLiteral.getProperties()) { + if (!Node.isPropertyAssignment(property)) { + // A shorthand entry still names its field; a spread or accessor could carry any field — + // either way the absence pass must not call an authored field missing on top of this + // finding (a non-static field is not an absent one). + if (Node.isShorthandPropertyAssignment(property)) { + authoredNames.add(property.getName()); + } else { + sawOpaqueEntry = true; + } + + failEnvelope( + property.getStartLineNumber(), + "the spec object literal must be fresh: only plain property assignments are static (a spread or shorthand entry could carry envelope fields opaquely)", + ); + continue; + } + + const name = readPropertyName(property); + + if (name === undefined) { + sawOpaqueEntry = true; + failEnvelope(property.getStartLineNumber(), "computed property names are non-static"); + continue; + } + + if (authoredNames.has(name)) { + failEnvelope(property.getStartLineNumber(), duplicatePropertyMessage(name), name); + continue; + } + + authoredNames.add(name); + const initializer = property.getInitializer(); + + if (initializer === undefined) { + failEnvelope( + property.getStartLineNumber(), + `property "${name}" carries no initializer`, + name, + ); + continue; + } + + if (name === "id") { + const idResult = reifyStaticIdExpression(initializer, ["spec"], bindings, "id"); + + if (!idResult.ok) { + appendIdFinding(idResult, file, subjectId, "id", findings); + envelopeOk = false; + continue; + } + + data.id = idResult.id; + continue; + } + + const enumField = SPEC_ENUM_FIELDS.find((field) => field.name === name); + + if (enumField !== undefined) { + const result = reifyStaticString(initializer, name); + + if (!result.ok) { + failEnvelope( + result.failure.line, + `envelope field "${name}" did not reify: ${result.failure.reason}`, + name, + ); + continue; + } + + const text = result.value as string; + + if (!enumField.values.has(text)) { + failEnvelope( + property.getStartLineNumber(), + `envelope field "${name}" reified to "${text}", which is not one of ${enumField.label} — the typed envelope cannot carry it`, + name, + ); + continue; + } + + data[name] = text; + continue; + } + + if (name === "relations") { + const relationsResult = reifyRelations(initializer, file, subjectId, bindings, findings); + + if (!relationsResult.ok) { + envelopeOk = false; + continue; + } + + data.relations = relationsResult.relations; + continue; + } + + if (name === "title") { + const result = reifyStaticString(initializer, "title"); + + if (!result.ok) { + appendDropFindings( + [ + { + droppedPath: "title", + failurePath: result.failure.path, + line: result.failure.line, + reason: result.failure.reason, + }, + ], + file, + subjectId, + findings, + ); + continue; + } + + data.title = result.value; + continue; + } + + if (RESERVED_DERIVED_PROPERTIES.has(name)) { + findings.push( + createExtractFinding( + extractFindingIds.reservedProperty, + "error", + `spec field "${name}" states derived graph vocabulary — delivery facts and derived edges are computed by the extractor, never authored, so the spec is not extracted`, + file, + property.getStartLineNumber(), + subjectId, + name, + ), + ); + envelopeOk = false; + continue; + } + + if (!SPEC_SECTION_NAME_SET.has(name)) { + findings.push( + createExtractFinding( + extractFindingIds.unrecognizedProperty, + "warning", + `property "${name}" is outside the spec shape (the envelope plus the sections ${SPEC_SECTION_NAMES.join(" · ")}) and is dropped — authored content must never silently fall out of the graph (L2)`, + file, + property.getStartLineNumber(), + subjectId, + name, + ), + ); + continue; + } + + // The section tier: the eight ratified sections reify lossily, so static content (including + // an in-section smuggled delivery fact, which the authoring-shape honesty check must get to + // see) survives and only the non-static parts drop, loudly. + const inner = unwrapTransparent(initializer); + + if (Node.isObjectLiteralExpression(inner)) { + const lossy = reifyObjectLossy(inner, name, bindings); + data[name] = lossy.value; + appendDropFindings(lossy.drops, file, subjectId, findings); + continue; + } + + const result = reifyStaticValue(initializer, name, bindings); + + if (result.ok) { + data[name] = result.value; + continue; + } + + appendDropFindings( + [ + { + droppedPath: name, + failurePath: result.failure.path, + line: result.failure.line, + reason: result.failure.reason, + }, + ], + file, + subjectId, + findings, + ); + } + + // Absence is judged on authored names, never on reified values: every genuinely missing field + // is reported in one pass, and a field that was authored but failed to reify already carries + // its own finding. An opaque entry (spread, accessor, computed name) could carry any field, so + // beside one nothing can honestly be called missing. + for (const required of ["id", "kind", "altitude", "readiness"]) { + if (!authoredNames.has(required) && !sawOpaqueEntry) { + failEnvelope( + call.getStartLineNumber(), + `envelope field "${required}" is missing — the typed envelope cannot be constructed without it`, + required, + ); + } + } + + if (!envelopeOk || findings.some((finding) => finding.severity === "error")) { + return { findings }; + } + + return { + entry: { + data, + id: data.id as string, + file, + line: call.getStartLineNumber(), + }, + findings, + }; +} + +function reifyIdArray( + node: Node, + fieldName: string, + file: string, + subjectId: string | undefined, + bindings: ProtocolBindings, + findings: Finding[], +): { readonly ok: boolean; readonly ids: readonly string[] } { + const unwrapped = unwrapTransparent(node); + + if (!Node.isArrayLiteralExpression(unwrapped)) { + findings.push( + createExtractFinding( + extractFindingIds.nonStaticEnvelope, + "error", + `envelope field "${fieldName}" must be an array literal`, + file, + unwrapped.getStartLineNumber(), + subjectId, + fieldName, + ), + ); + + return { ok: false, ids: [] }; + } + + let ok = true; + const ids: string[] = []; + + for (const [index, element] of unwrapped.getElements().entries()) { + const entryPath = `${fieldName}[${String(index)}]`; + const idResult = reifyStaticIdExpression(element, ["spec"], bindings, entryPath); + + if (!idResult.ok) { + appendIdFinding(idResult, file, subjectId, entryPath, findings); + ok = false; + continue; + } + + ids.push(idResult.id); + } + + return { ok, ids }; +} + +function reifyPackCall( + call: CallExpression, + file: string, + bindings: ProtocolBindings, +): CallReification { + const findings: Finding[] = []; + const objectLiteral = requireSingleObjectArgument(call, "pack", file, findings); + + if (objectLiteral === undefined) { + return { findings }; + } + + const subjectId = peekId(objectLiteral, ["pack"], bindings); + const data: Record = {}; + const authoredNames = new Set(); + let sawOpaqueEntry = false; + let envelopeOk = true; + + const failEnvelope = (line: number, message: string, path?: string): void => { + findings.push( + createExtractFinding( + extractFindingIds.nonStaticEnvelope, + "error", + message, + file, + line, + subjectId, + path, + ), + ); + envelopeOk = false; + }; + + for (const property of objectLiteral.getProperties()) { + if (!Node.isPropertyAssignment(property)) { + // The absence pass must not call an authored field missing (a non-static field is not an + // absent one): a shorthand entry still names its field; a spread or accessor is opaque. + if (Node.isShorthandPropertyAssignment(property)) { + authoredNames.add(property.getName()); + } else { + sawOpaqueEntry = true; + } + + failEnvelope( + property.getStartLineNumber(), + "the pack object literal must be fresh: only plain property assignments are static (a spread or shorthand entry could carry envelope fields opaquely)", + ); + continue; + } + + const name = readPropertyName(property); + + if (name === undefined) { + sawOpaqueEntry = true; + failEnvelope(property.getStartLineNumber(), "computed property names are non-static"); + continue; + } + + if (authoredNames.has(name)) { + failEnvelope(property.getStartLineNumber(), duplicatePropertyMessage(name), name); + continue; + } + + authoredNames.add(name); + const initializer = property.getInitializer(); + + if (initializer === undefined) { + failEnvelope( + property.getStartLineNumber(), + `property "${name}" carries no initializer`, + name, + ); + continue; + } + + if (name === "id") { + const idResult = reifyStaticIdExpression(initializer, ["pack"], bindings, "id"); + + if (!idResult.ok) { + appendIdFinding(idResult, file, subjectId, "id", findings); + envelopeOk = false; + continue; + } + + data.id = idResult.id; + continue; + } + + if (name === "specs" || name === "modelRefs") { + const result = reifyIdArray(initializer, name, file, subjectId, bindings, findings); + + if (!result.ok) { + envelopeOk = false; + continue; + } + + data[name] = result.ids; + continue; + } + + if (name === "title" || name === "framing") { + const result = reifyStaticString(initializer, name); + + if (!result.ok) { + appendDropFindings( + [ + { + droppedPath: name, + failurePath: result.failure.path, + line: result.failure.line, + reason: result.failure.reason, + }, + ], + file, + subjectId, + findings, + ); + continue; + } + + data[name] = result.value; + continue; + } + + if (RESERVED_DERIVED_PROPERTIES.has(name)) { + findings.push( + createExtractFinding( + extractFindingIds.reservedProperty, + "error", + `pack field "${name}" states derived graph vocabulary — delivery facts and derived edges are computed by the extractor, never authored (a pack states no truth of its own), so the pack is not extracted`, + file, + property.getStartLineNumber(), + subjectId, + name, + ), + ); + envelopeOk = false; + continue; + } + + // The pack manifest has no section tier: every authored field is named above, so anything + // else drops loudly (L2) instead of riding into the model unread. + findings.push( + createExtractFinding( + extractFindingIds.unrecognizedProperty, + "warning", + `property "${name}" is outside the pack manifest shape (id · specs · modelRefs · title · framing) and is dropped — authored content must never silently fall out of the graph (L2)`, + file, + property.getStartLineNumber(), + subjectId, + name, + ), + ); + } + + // Absence is judged on authored names, never on reified values (see `reifySpecCall`). + for (const required of ["id", "specs"]) { + if (!authoredNames.has(required) && !sawOpaqueEntry) { + failEnvelope( + call.getStartLineNumber(), + `envelope field "${required}" is missing — the pack manifest cannot be constructed without it`, + required, + ); + } + } + + if (!envelopeOk || findings.some((finding) => finding.severity === "error")) { + return { findings }; + } + + return { + entry: { + data, + id: data.id as string, + file, + line: call.getStartLineNumber(), + }, + findings, + }; +} + +/* ----- the recognized statement set ----- */ + +function unrecognizedStatement(file: string, line: number, message: string): Finding { + return createExtractFinding( + extractFindingIds.unrecognizedStatement, + "warning", + `${message}; the statement is ignored (the recognized set: imports from "${PROTOCOL_MODULE_SPECIFIER}" and const declarations initialized with spec(…)/pack(…))`, + file, + line, + ); +} + +/** + * Reifies one `*.sdp.ts` file standalone — no type checker, no import following. Anything outside + * the recognized statement set is ignored with a loud warning rather than a hard error: the base + * defines no hard-error class for foreign statements (the `sdp/spec-static` lint stays future + * work). + */ +export function reifySourceFile(sourceFile: SourceFile, relativePath: string): FileReification { + const bindings = collectProtocolBindings(sourceFile); + const specs: ReifiedSpec[] = []; + const packs: ReifiedPack[] = []; + const findings: Finding[] = []; + + for (const statement of sourceFile.getStatements()) { + if (Node.isImportDeclaration(statement)) { + if (statement.getModuleSpecifierValue() === PROTOCOL_MODULE_SPECIFIER) { + continue; + } + + findings.push( + unrecognizedStatement( + relativePath, + statement.getStartLineNumber(), + `import from "${statement.getModuleSpecifierValue()}" is not the protocol package — identifiers it binds are non-static wherever they appear`, + ), + ); + continue; + } + + if (Node.isVariableStatement(statement)) { + if (statement.getDeclarationKind() !== VariableDeclarationKind.Const) { + findings.push( + unrecognizedStatement( + relativePath, + statement.getStartLineNumber(), + "only const declarations are recognized", + ), + ); + continue; + } + + for (const declaration of statement.getDeclarations()) { + const initializer = declaration.getInitializer(); + const builderCall = + initializer === undefined ? undefined : resolveBuilderCall(initializer, bindings); + + if (builderCall?.builder === "spec") { + const result = reifySpecCall(builderCall.call, relativePath, bindings); + findings.push(...result.findings); + + if (result.entry !== undefined) { + specs.push(result.entry); + } + + continue; + } + + if (builderCall?.builder === "pack") { + const result = reifyPackCall(builderCall.call, relativePath, bindings); + findings.push(...result.findings); + + if (result.entry !== undefined) { + packs.push(result.entry); + } + + continue; + } + + findings.push( + unrecognizedStatement( + relativePath, + declaration.getStartLineNumber(), + `const "${declaration.getName()}" is not initialized with a spec(…)/pack(…) call bound to the protocol package`, + ), + ); + } + + continue; + } + + findings.push( + unrecognizedStatement( + relativePath, + statement.getStartLineNumber(), + `${statement.getKindName()} is outside the authored grammar`, + ), + ); + } + + return { specs, packs, findings }; +} diff --git a/src/extract/serialize.ts b/src/extract/serialize.ts new file mode 100644 index 0000000..157dc30 --- /dev/null +++ b/src/extract/serialize.ts @@ -0,0 +1,99 @@ +import type { GraphEdge, GraphNode, GraphSchema } from "../graph/schema.js"; + +/** + * Every output byte is owned here, so no `ts-morph` upgrade can change them silently: nodes sorted + * by `id`, edges by `(from, type, to)` (P3), one canonical key order per node/edge shape, 2-space + * indent, LF, final newline, UTF-8 without BOM, no wall-clock timestamps, no run hashes, no + * absolute paths (JS-C3). Section content is the one exception to canonical key order: it + * serializes in authored order — it is content, and authored order is deterministic given the + * repo. + * + * Sorting is code-unit string comparison, never `localeCompare`: locale-aware collation is + * environment-dependent and would break determinism. + */ +function compareCodeUnits(left: string, right: string): number { + if (left < right) { + return -1; + } + + return left > right ? 1 : 0; +} + +function canonicalNode(node: GraphNode): Record { + switch (node.nodeType) { + case "Primitive": + return { + id: node.id, + nodeType: node.nodeType, + claim: node.claim, + specKind: node.specKind, + altitude: node.altitude, + readiness: node.readiness, + ...(node.title === undefined ? {} : { title: node.title }), + file: node.file, + ...(node.sections === undefined ? {} : { sections: node.sections }), + ...(node.deliveryFacts === undefined || node.deliveryFacts.length === 0 + ? {} + : { deliveryFacts: node.deliveryFacts }), + }; + case "Pack": + return { + id: node.id, + nodeType: node.nodeType, + claim: node.claim, + ...(node.title === undefined ? {} : { title: node.title }), + ...(node.framing === undefined ? {} : { framing: node.framing }), + file: node.file, + ...(node.modelRefs === undefined ? {} : { modelRefs: node.modelRefs }), + }; + case "Anchor": + return { + id: node.id, + nodeType: node.nodeType, + claim: node.claim, + ...(node.label === undefined ? {} : { label: node.label }), + file: node.file, + line: node.line, + }; + case "CodeNode": + return { + id: node.id, + nodeType: node.nodeType, + claim: node.claim, + ...(node.label === undefined ? {} : { label: node.label }), + file: node.file, + ...(node.line === undefined ? {} : { line: node.line }), + }; + } +} + +function canonicalEdge(edge: GraphEdge): Record { + return { + from: edge.from, + type: edge.type, + to: edge.to, + claim: edge.claim, + }; +} + +function compareNodes(left: GraphNode, right: GraphNode): number { + return compareCodeUnits(left.id, right.id); +} + +function compareEdges(left: GraphEdge, right: GraphEdge): number { + return ( + compareCodeUnits(left.from, right.from) || + compareCodeUnits(left.type, right.type) || + compareCodeUnits(left.to, right.to) + ); +} + +export function serializeGraph(graph: GraphSchema): string { + const canonical = { + schemaVersion: graph.schemaVersion, + nodes: [...graph.nodes].sort(compareNodes).map(canonicalNode), + edges: [...graph.edges].sort(compareEdges).map(canonicalEdge), + }; + + return `${JSON.stringify(canonical, null, 2)}\n`; +} diff --git a/src/graph/delivery-facts.ts b/src/graph/delivery-facts.ts new file mode 100644 index 0000000..7ed8d83 --- /dev/null +++ b/src/graph/delivery-facts.ts @@ -0,0 +1,141 @@ +import type { DeliveryFactName, GraphEdge, GraphNode, PrimitiveNode } from "./schema.js"; + +/** + * Delivery facts, computed exactly per `02` §2 — derived from edges, never authored. This is the + * one derivation rule, shared by the extractor (which decorates `Primitive` nodes with it) and + * the delivery-facts honesty check (which recomputes it over the same graph and compares) — one + * derivation path, never two: + * + * - `implemented` — ≥1 `satisfies` edge resolves to the spec along its `03` §1 contract row + * (anchored, from a `CodeNode` present in the graph). A dangling or off-contract binding + * confers nothing. + * - `has-verifier` — an **anchored** `verifies` edge resolves to the spec along its contract row + * (from an `Anchor` node present in the graph — a test anchored to the spec), or a **declared** + * `verifies` edge from an *enabled* example resolves to it — enabled = an `example`-kind spec + * that is itself the target of a resolving anchored `verifies` edge. Direct, per-spec, never + * propagated up `refines`; a declared `verifies` from a verifier that is not enabled confers + * nothing (binding, never liveness — MD-7). + * - `observed` — never computed (aspirational, the liveness rung). + * + * Extractor output satisfies the contract-row conditions by construction (a binding node and its + * edge derive together, claims assigned by flavor); the conditions have teeth for any other graph + * producer — an edge the claim-separation check would reject never confers a fact (fail closed). + * + * Facts are listed in ladder order (`implemented` → `has-verifier`). + */ +/** + * The one resolving-test-anchor rule: an anchored `verifies` edge counts only along its `03` §1 + * contract row — its source resolves to an `Anchor` node present in the graph. Shared by + * delivery-fact derivation, the verifies-linkage check, and the reader's enabled decode so the + * three surfaces can never disagree: on a malformed or foreign graph an off-contract edge (wrong + * claim, non-Anchor source) confers nothing (fail closed). + */ +export function isResolvingTestAnchorVerify( + edge: GraphEdge, + nodesById: ReadonlyMap, +): boolean { + return ( + edge.type === "verifies" && + edge.claim === "anchored" && + nodesById.get(edge.from)?.nodeType === "Anchor" + ); +} + +/** + * The one enabled-example rule — the declared twin of the resolving-test-anchor rule: a declared + * `verifies` edge confers the binding only along its `02` §2 contract row — its source resolves, + * by first carrier exactly as the graph index keys, to an `example`-kind `Primitive` that a + * resolving test anchor binds. Shared by delivery-fact derivation and the reader's enabled decode + * so the two conferral surfaces can never disagree — including on a duplicate-id graph, where + * both key the same first carrier (the duplicate-ids check reports the ambiguity loudly, L2). + * The verifies-linkage check stays the per-cause loud twin: it names *why* a declared verifier + * confers nothing; this predicate only decides *whether*. + */ +export function isEnabledExampleVerify( + edge: GraphEdge, + nodesById: ReadonlyMap, + anchorVerified: (verifierId: string) => boolean, +): boolean { + if (edge.type !== "verifies" || edge.claim !== "declared") { + return false; + } + + const source = nodesById.get(edge.from); + + return ( + source?.nodeType === "Primitive" && source.specKind === "example" && anchorVerified(source.id) + ); +} + +export function computeDeliveryFacts( + nodes: readonly GraphNode[], + edges: readonly GraphEdge[], +): ReadonlyMap { + const nodesById = new Map(); + const primitivesById = new Map(); + + for (const node of nodes) { + // Duplicate ids cannot be keyed; the first carrier wins here, exactly as in the graph index, + // and the duplicate-ids validator reports the ambiguity loudly (L2). + if (!nodesById.has(node.id)) { + nodesById.set(node.id, node); + } + + if (node.nodeType === "Primitive" && !primitivesById.has(node.id)) { + primitivesById.set(node.id, node); + } + } + + const implemented = new Set(); + const anchorVerified = new Set(); + + for (const edge of edges) { + if (!primitivesById.has(edge.to)) { + continue; + } + + if ( + edge.type === "satisfies" && + edge.claim === "anchored" && + nodesById.get(edge.from)?.nodeType === "CodeNode" + ) { + implemented.add(edge.to); + } + + if (isResolvingTestAnchorVerify(edge, nodesById)) { + anchorVerified.add(edge.to); + } + } + + const hasVerifier = new Set(anchorVerified); + + for (const edge of edges) { + if (!primitivesById.has(edge.to)) { + continue; + } + + if (isEnabledExampleVerify(edge, nodesById, (verifierId) => anchorVerified.has(verifierId))) { + hasVerifier.add(edge.to); + } + } + + const facts = new Map(); + + for (const id of primitivesById.keys()) { + const ladder: DeliveryFactName[] = []; + + if (implemented.has(id)) { + ladder.push("implemented"); + } + + if (hasVerifier.has(id)) { + ladder.push("has-verifier"); + } + + if (ladder.length > 0) { + facts.set(id, ladder); + } + } + + return facts; +} diff --git a/src/graph/schema.ts b/src/graph/schema.ts index 4d09206..4781346 100644 --- a/src/graph/schema.ts +++ b/src/graph/schema.ts @@ -1,6 +1,7 @@ import type { SpecAltitude, SpecKind, SpecReadiness } from "../model/descriptors.js"; +import type { SpecSections } from "../model/sections.js"; -export const schemaVersion = "0.1.0" as const; +export const schemaVersion = "0.3.0" as const; export const graphNodeTypes = ["Primitive", "Pack", "Anchor", "CodeNode"] as const; export type GraphNodeType = (typeof graphNodeTypes)[number]; @@ -38,19 +39,49 @@ export interface PrimitiveNode extends GraphNodeBase { readonly specKind: SpecKind; readonly altitude: SpecAltitude; readonly readiness: SpecReadiness; + /** Degradable: a non-static title is dropped with a warning, never a hard error (`03` §2). */ + readonly title?: string; + /** Extraction-root-relative, POSIX separators, no leading `./` — never absolute (JS-C3). */ + readonly file: string; + /** + * The reified section content rides the node (the graph is the sole input every consumer reads — + * P2): structural metadata stays flat above; content is fenced here, in authored order. Omitted + * when the spec carries no sections. + */ + readonly sections?: SpecSections; readonly deliveryFacts?: readonly DeliveryFactName[]; } export interface PackNode extends GraphNodeBase { readonly nodeType: "Pack"; + readonly title?: string; + readonly framing?: string; + /** + * Node data, not edges: the `03` edge contract has no `modelRefs` edge type — the + * pack-coherence check reads this (every entry resolves to a `model`-kind spec). + */ + readonly modelRefs?: readonly string[]; + readonly file: string; } +/** + * The test anchor's node (the `verifies` edge contract row: Anchor (test) → Primitive, anchored). + * Binding nodes carry `file` + `line`: the line *is* the binding location — what a Design Review + * links to (consumers may link to source locations recorded in the graph, R2). `Primitive`/`Pack` + * nodes stay line-free so the golden stays robust to spec-file editing. + */ export interface AnchorNode extends GraphNodeBase { readonly nodeType: "Anchor"; + readonly label?: string; + /** Extraction-root-relative, POSIX separators, no leading `./` — never absolute (JS-C3). */ + readonly file: string; + readonly line: number; } +/** A code anchor's node (the `satisfies` edge contract row: CodeNode → Primitive, anchored). */ export interface CodeNode extends GraphNodeBase { readonly nodeType: "CodeNode"; + readonly label?: string; readonly file: string; readonly line?: number; } diff --git a/src/ids.ts b/src/ids.ts index 14ecc70..870b464 100644 --- a/src/ids.ts +++ b/src/ids.ts @@ -8,8 +8,19 @@ const PATH_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]*$/u; export type SpecId = Brand<"SpecId">; export type PackId = Brand<"PackId">; export type AnchorId = Brand<"AnchorId">; -export type ImplAnchorId = AnchorId & Brand<"ImplAnchorId">; -export type TestAnchorId = AnchorId & Brand<"TestAnchorId">; +// The flavor layers a second key over the `AnchorId` brand rather than intersecting a second +// `Brand<…>`: two disjoint unit types on one `__brand` key reduce the intersection to `never`, +// and `never` assigns everywhere — tsc would stop enforcing the flavored ids entirely. +export type CodeAnchorId = AnchorId & { readonly __anchorFlavor: "code" }; +export type TestAnchorId = AnchorId & { readonly __anchorFlavor: "test" }; + +/** + * The implementation-flavored code namespaces a `codeAnchor` may bind (the generic `codeAnchor`, + * MD-8): any code location — a class, function, route, or module — regardless of how the runtime + * is wired. The `test:` namespace is deliberately not here: a test anchor is the *verifying* + * binding (`specTest`), a different binding direction, not a fourth code flavor. + */ +export const CODE_ANCHOR_NAMESPACES = ["impl", "api", "component"] as const; export interface IdParts { readonly namespace: string; @@ -96,12 +107,17 @@ function validateIdShape(value: string): IdParts { function requireNamespace( value: string, - expectedNamespace: string, + expectedNamespaces: readonly string[], ): Brand { const parsed = validateIdShape(value); - if (parsed.namespace !== expectedNamespace) { - failId(value, `expected namespace "${expectedNamespace}"`); + if (!expectedNamespaces.includes(parsed.namespace)) { + failId( + value, + expectedNamespaces.length === 1 + ? `expected namespace "${expectedNamespaces[0] ?? ""}"` + : `expected one of the namespaces ${expectedNamespaces.map((entry) => `"${entry}"`).join(" · ")}`, + ); } return brandId(value); @@ -123,23 +139,23 @@ export function anchorId(value: string): AnchorId { } export function specId(value: string): SpecId { - return requireNamespace<"SpecId">(value, "spec"); + return requireNamespace<"SpecId">(value, ["spec"]); } export function packId(value: string): PackId { - return requireNamespace<"PackId">(value, "pack"); + return requireNamespace<"PackId">(value, ["pack"]); } -export function implAnchorId(value: string): ImplAnchorId { - return requireNamespace<"ImplAnchorId">(value, "impl") as ImplAnchorId; +export function codeAnchorId(value: string): CodeAnchorId { + return requireNamespace<"AnchorId">(value, CODE_ANCHOR_NAMESPACES) as CodeAnchorId; } export function testAnchorId(value: string): TestAnchorId { - return requireNamespace<"TestAnchorId">(value, "test") as TestAnchorId; + return requireNamespace<"AnchorId">(value, ["test"]) as TestAnchorId; } /** - * `ref()` is today a spec-only reference builder wearing a generic name: it is `specId` aliased, so + * `ref()` is a spec-only reference builder wearing a generic name: it is `specId` aliased, so * it rejects `pack:` / `doc:` targets — a named deferral (carried evidence, MD-16). Harmless while * every call site wants a spec; revisit when `doc:`-target relations (`decidedBy` → an external ADR) * or pack-targeting arrive. diff --git a/src/index.ts b/src/index.ts index cae0cd9..a71de5f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ export * from "./ids.js"; +export * from "./extract/index.js"; +export * from "./graph/delivery-facts.js"; export * from "./graph/schema.js"; export * from "./model/anchors.js"; export * from "./model/descriptors.js"; @@ -6,7 +8,9 @@ export * from "./model/pack.js"; export * from "./model/relations.js"; export * from "./model/sections.js"; export * from "./model/spec.js"; -export * from "./validate/authored-model.js"; +export * from "./projections/design-review.js"; +export * from "./reader/reader.js"; export * from "./validate/contracts.js"; +export * from "./validate/graph-index.js"; export * from "./validate/readiness-floor.js"; export * from "./validate/validators.js"; diff --git a/src/model/anchors.ts b/src/model/anchors.ts index 63dd36e..51304d8 100644 --- a/src/model/anchors.ts +++ b/src/model/anchors.ts @@ -1,20 +1,34 @@ -import type { ImplAnchorId, SpecId, TestAnchorId } from "../ids.js"; +import type { CodeAnchorId, SpecId, TestAnchorId } from "../ids.js"; -export interface ImplementationAnchor { - readonly id: ImplAnchorId; +/** + * The generic code anchor (MD-8, folded here): one builder over the implementation-flavored code + * namespaces (`impl` / `api` / `component`), because anchors are generic *by definition* — the + * binding is the thing, framework- and location-neutral (`04` §2), and the ID grammar already + * parses any lowercase namespace. Per-namespace sibling builders (`anchorApi`, `anchorComponent`, + * …) were rejected as surface bloat for zero expressive gain. An anchor asserts a binding only, + * never system-truth content (R1): identity, an optional display label, and the one `satisfies` + * target — nothing spec-level ever rides here. + */ +export interface CodeAnchor { + readonly id: CodeAnchorId; readonly label?: string; readonly satisfies: SpecId; } +/** + * The binding-only test anchor (R3): identity plus the `verifies` target, never an executing + * callback — the graph records that an enabled verifier *exists*, never that it ran (binding, + * never liveness — MD-7). + */ export interface SpecTestAnchor { readonly id: TestAnchorId; readonly label?: string; readonly verifies: SpecId; } -export type Anchor = ImplementationAnchor | SpecTestAnchor; +export type Anchor = CodeAnchor | SpecTestAnchor; -export function anchorImplementation(anchor: ImplementationAnchor): ImplementationAnchor { +export function codeAnchor(anchor: CodeAnchor): CodeAnchor { return { ...anchor, }; diff --git a/src/model/relations.ts b/src/model/relations.ts index d498c1b..8f78f7f 100644 --- a/src/model/relations.ts +++ b/src/model/relations.ts @@ -14,7 +14,7 @@ export type SpecRelationType = (typeof SPEC_RELATION_TYPES)[number]; export interface SpecRelation { readonly type: TType; /** - * Authored relation targets are `spec:`-only today. The base reserves `decidedBy` → `doc:` for a + * Authored relation targets are `spec:`-only. The base reserves `decidedBy` → `doc:` for a * genuinely external ADR (`02` §6), but that target type is a named deferral until the need * arrives (MD-16) — see also the `ref()` note in `ids.ts`. */ diff --git a/src/projections/design-review.ts b/src/projections/design-review.ts new file mode 100644 index 0000000..d243cfe --- /dev/null +++ b/src/projections/design-review.ts @@ -0,0 +1,684 @@ +import { SPEC_READINESS } from "../model/descriptors.js"; +import type { Finding } from "../validate/contracts.js"; +import type { + PackContext, + Reader, + RelationEnd, + SpecContext, + SpecSummary, + VerifierBinding, +} from "../reader/reader.js"; + +/** + * The Design Review — the one MVP read-only human view (`06` §5): a pure projection of the one + * graph, rendered as Markdown pages (index + one page per spec and per pack). Fully derived and + * regenerable; consumes **only** the reader (the one decode path — re-joining the graph here + * would be the consumption-side second store), and links only to source locations the graph + * records (R2). Pages carry no timestamps and no commit hashes: the view is `f(graph)`, nothing + * else, so regeneration from the same graph is byte-identical. + * + * Views speak binding language (MD-7): the delivery-fact names stay internal; what renders is + * "Implementation binding / Verifier binding / Runtime observation: not tracked" — bindings, + * never liveness. Stated readiness renders beside the structurally-reached floor, and the + * divergence banner fires only in the dishonest direction (`05` §3). + */ +export interface DesignReviewPage { + /** POSIX path under the view root (`generated/design-review/`), e.g. `spec/orders.create-order.md`. */ + readonly path: string; + readonly content: string; +} + +/* ----- deterministic Markdown plumbing ----- */ + +/** `spec:orders.create-order` → `spec/orders.create-order.md` — bijective by the id grammar (one `:`). */ +function pagePathOf(id: string): string { + const colonIndex = id.indexOf(":"); + + return `${id.slice(0, colonIndex)}/${id.slice(colonIndex + 1)}.md`; +} + +function directoryOf(pagePath: string): readonly string[] { + return pagePath.split("/").slice(0, -1); +} + +/** Relative link between two view pages. */ +function pageHref(fromPage: string, toPage: string): string { + const fromDirectory = directoryOf(fromPage); + const toParts = toPage.split("/"); + let shared = 0; + + while (shared < fromDirectory.length && fromDirectory[shared] === toParts[shared]) { + shared += 1; + } + + return `${"../".repeat(fromDirectory.length - shared)}${toParts.slice(shared).join("/")}`; +} + +/** Relative link from a view page to a repo file recorded in the graph (root-relative, JS-C3). */ +function sourceHref(fromPage: string, file: string): string { + // Up out of the page's directories, then out of `design-review/` and `generated/`. + return `${"../".repeat(directoryOf(fromPage).length + 2)}${file}`; +} + +/** One-line table cell: pipes escaped, newlines collapsed — content never breaks the table. */ +function tableCell(text: string): string { + return text.replaceAll("|", "\\|").replaceAll(/\s+/gu, " ").trim(); +} + +function heading(title: string | undefined, id: string): string { + return `# ${title ?? id}`; +} + +const PAGE_FOOTER = + "*Generated from the one graph by `sdp view` — read-only; regenerate to update.*"; + +/* ----- defensive value access (sections are reified value data, never typed instances) ----- */ + +function asRecord(value: unknown): Record | undefined { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return value as Record; + } + + return undefined; +} + +function asArray(value: unknown): readonly unknown[] | undefined { + return Array.isArray(value) ? (value as readonly unknown[]) : undefined; +} + +function asText(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function textEntries(value: unknown): readonly string[] { + return (asArray(value) ?? []).flatMap((entry) => { + const text = asText(entry); + + return text === undefined ? [] : [text]; + }); +} + +/* ----- the readiness header (`05` §3, `07` §6 ③) ----- */ + +function renderReadiness(context: SpecContext): readonly string[] { + const derived = context.derivedReadiness; + const lines = [ + `**Readiness:** stated \`${context.statedReadiness}\` · structural floor reached: ${ + derived === undefined ? "none (the `idea` floor is unmet)" : `\`${derived}\`` + }`, + ]; + + const statedRank = SPEC_READINESS.indexOf(context.statedReadiness); + const derivedRank = derived === undefined ? -1 : SPEC_READINESS.indexOf(derived); + + // The banner fires only in the dishonest direction: derived at-or-above stated is ordinary + // information (the floor is never a quota and never nags upward). + if (derivedRank < statedRank) { + const firstUnmet = context.floorFailures[0]; + const clause = + firstUnmet === undefined + ? "" + : ` First unmet clause: \`${firstUnmet.clauseId}\` — ${firstUnmet.description}`; + lines.push( + "", + `> **Readiness divergence.** This spec states \`${context.statedReadiness}\` but the structural floor reached is ${ + derived === undefined ? "below `idea`" : `\`${derived}\`` + }.${clause}`, + ); + } + + return lines; +} + +/* ----- bindings in binding language (`07` §6 ④) ----- */ + +function describeVerifier(verifier: VerifierBinding): string { + if (verifier.via === "test-anchor") { + return verifier.enabled + ? "the enabled verifying binding (a resolving test anchor)" + : "**not enabled** (an off-contract `verifies` edge — it confers no verifier binding)"; + } + + if (verifier.enabled) { + return "**enabled** (a resolving test anchor binds this example)"; + } + + // Name the actual cause: an off-contract claim confers nothing even when a test anchor binds + // the example, so blaming a missing anchor would send the reader hunting for one that exists. + return verifier.claim === "declared" + ? "**not enabled** (no test anchor binds this example — it confers no verifier binding)" + : "**not enabled** (an off-contract `verifies` edge — it confers no verifier binding)"; +} + +function renderBindings(context: SpecContext, page: string): readonly string[] { + const present = (fact: "implemented" | "has-verifier"): string => + context.deliveryFacts.includes(fact) ? "present" : "none"; + + const lines = [ + "## Bindings", + "", + `- Implementation binding: **${present("implemented")}**`, + `- Verifier binding: **${present("has-verifier")}**`, + "- Runtime observation: **not tracked**", + ]; + + if (context.implementations.length > 0) { + lines.push("", "### Implementations", ""); + + for (const binding of context.implementations) { + const label = binding.label === undefined ? "" : ` — ${binding.label}`; + const location = + binding.file === undefined + ? "" + : ` ([${binding.file}${binding.line === undefined ? "" : `:${String(binding.line)}`}](${sourceHref(page, binding.file)}))`; + lines.push(`- \`${binding.codeId}\`${label}${location} \`[${binding.claim}]\``); + } + } + + if (context.verifiers.length > 0) { + lines.push("", "### Verifiers", ""); + + for (const verifier of context.verifiers) { + const label = verifier.label === undefined ? "" : ` — ${verifier.label}`; + const location = + verifier.file === undefined + ? "" + : ` ([${verifier.file}${verifier.line === undefined ? "" : `:${String(verifier.line)}`}](${sourceHref(page, verifier.file)}))`; + lines.push( + `- \`${verifier.verifierId}\`${label}${location} — ${describeVerifier(verifier)} \`[${verifier.claim}]\``, + ); + } + } + + return lines; +} + +/* ----- section content ----- */ + +function renderIntent(intent: Record): readonly string[] { + const lines: string[] = ["## Intent", ""]; + + for (const field of ["actor", "problem", "outcome", "value"]) { + const text = asText(intent[field]); + + if (text !== undefined) { + lines.push(`- **${field}:** ${text}`); + } + } + + for (const field of ["risks", "assumptions"]) { + const entries = textEntries(intent[field]); + + if (entries.length > 0) { + lines.push(`- **${field}:**`); + lines.push(...entries.map((entry) => ` - ${entry}`)); + } + } + + const openQuestions = asArray(intent.openQuestions) ?? []; + + if (openQuestions.length > 0) { + lines.push("", "### Open questions", ""); + + for (const entry of openQuestions) { + const prose = asText(entry); + + if (prose !== undefined) { + lines.push(`- ${prose}`); + continue; + } + + const structured = asRecord(entry); + const question = asText(structured?.question) ?? "(malformed open-question entry)"; + const blocking = structured?.blocking === true ? " — **blocking**" : ""; + lines.push(`- ${question}${blocking}`); + } + } + + return lines; +} + +function renderBehavior(behavior: Record): readonly string[] { + const lines: string[] = ["## Behavior"]; + const rules = textEntries(behavior.rules); + + if (rules.length > 0) { + lines.push("", "### Rules", "", ...rules.map((rule) => `- ${rule}`)); + } + + const examples = asArray(behavior.examples) ?? []; + + if (examples.length > 0) { + lines.push("", "### Examples", ""); + + for (const entry of examples) { + const prose = asText(entry); + + if (prose !== undefined) { + lines.push(`- ${prose}`); + continue; + } + + const structured = asRecord(entry); + + if (structured === undefined) { + continue; + } + + lines.push("- Example:"); + + for (const phase of ["given", "when", "then"]) { + const steps = textEntries(structured[phase]); + + if (steps.length > 0) { + lines.push(` - **${phase}**`); + lines.push(...steps.map((step) => ` - ${step}`)); + } + } + } + } + + const flows = textEntries(behavior.flows); + + if (flows.length > 0) { + lines.push("", "### Flows", "", ...flows.map((flow) => `- ${flow}`)); + } + + return lines.length === 1 ? [] : lines; +} + +function renderConstraints(entries: readonly unknown[]): readonly string[] { + const lines = [ + "## Constraints", + "", + "| Flavor | Statement | Target | Measurable by |", + "|---|---|---|---|", + ]; + + for (const entry of entries) { + const constraint = asRecord(entry) ?? {}; + const cell = (field: string): string => tableCell(asText(constraint[field]) ?? "—"); + lines.push( + `| ${cell("flavor")} | ${cell("statement")} | ${cell("target")} | ${cell("measurableBy")} |`, + ); + } + + return lines; +} + +function renderModel(model: Record): readonly string[] { + const terms = asRecord(model.terms) ?? {}; + const names = Object.keys(terms); + + if (names.length === 0) { + return []; + } + + const lines = ["## Domain vocabulary", "", "| Term | Definition |", "|---|---|"]; + + for (const name of names) { + lines.push(`| ${tableCell(name)} | ${tableCell(asText(terms[name]) ?? "—")} |`); + } + + return lines; +} + +function renderDecision(decision: Record): readonly string[] { + const lines: string[] = ["## Decision"]; + const context = asText(decision.context); + + if (context !== undefined) { + lines.push("", `**Context.** ${context}`); + } + + const chosen = asText(decision.decision); + + if (chosen !== undefined) { + lines.push("", `**Decision.** ${chosen}`); + } + + for (const field of ["rationale", "alternatives", "consequences"]) { + const entries = textEntries(decision[field]); + + if (entries.length > 0) { + lines.push( + "", + `**${field[0]?.toUpperCase() ?? ""}${field.slice(1)}.**`, + "", + ...entries.map((entry) => `- ${entry}`), + ); + } + } + + return lines.length === 1 ? [] : lines; +} + +function renderVerification(verification: Record): readonly string[] { + const lines: string[] = ["## Verification intent"]; + const mode = asText(verification.mode); + + if (mode !== undefined) { + lines.push("", `- **mode:** \`${mode}\``); + } + + const criteria = textEntries(verification.criteria); + + if (criteria.length > 0) { + lines.push("", "### Criteria", "", ...criteria.map((criterion) => `- ${criterion}`)); + } + + return lines.length === 1 ? [] : lines; +} + +/** The open bags (`design` / `ui`, L9): authored order preserved, rendered as data. */ +function renderOpenBag(name: string, content: Record): readonly string[] { + if (Object.keys(content).length === 0) { + return []; + } + + return [ + `## ${name[0]?.toUpperCase() ?? ""}${name.slice(1)}`, + "", + "```json", + ...JSON.stringify(content, null, 2).split("\n"), + "```", + ]; +} + +function renderSections(context: SpecContext): readonly string[] { + const sections = (context.sections ?? {}) as Record; + const lines: string[] = []; + const append = (rendered: readonly string[]): void => { + if (rendered.length > 0) { + lines.push("", ...rendered); + } + }; + + const intent = asRecord(sections.intent); + + if (intent !== undefined) { + append(renderIntent(intent)); + } + + const behavior = asRecord(sections.behavior); + + if (behavior !== undefined) { + append(renderBehavior(behavior)); + } + + const constraints = asArray(sections.constraints); + + if (constraints !== undefined && constraints.length > 0) { + append(renderConstraints(constraints)); + } + + const model = asRecord(sections.model); + + if (model !== undefined) { + append(renderModel(model)); + } + + const decision = asRecord(sections.decision); + + if (decision !== undefined) { + append(renderDecision(decision)); + } + + const verification = asRecord(sections.verification); + + if (verification !== undefined) { + append(renderVerification(verification)); + } + + for (const name of ["design", "ui"]) { + const bag = asRecord(sections[name]); + + if (bag !== undefined) { + append(renderOpenBag(name, bag)); + } + } + + return lines; +} + +/* ----- relations, impact, findings ----- */ + +function linkTo(page: string, end: RelationEnd): string { + const display = end.otherTitle === undefined ? "" : ` — ${end.otherTitle}`; + + if (end.resolved && (end.otherNodeType === "Primitive" || end.otherNodeType === "Pack")) { + return `[\`${end.otherId}\`](${pageHref(page, pagePathOf(end.otherId))})${display}`; + } + + return end.resolved + ? `\`${end.otherId}\`${display}` + : `\`${end.otherId}\` — **unresolved** (see findings)`; +} + +/** + * One section, two readings (JS-E1/JS-G1): the relation list *is* the per-spec impact list — + * every line is a one-hop neighbor, so a change to this spec touches exactly this list plus the + * bindings above. Stated once, never as a second list to drift. + */ +function renderRelationsAndImpact(context: SpecContext, page: string): readonly string[] { + // Incoming `verifies` edges render under Bindings (the decoded verifier join), never twice; + // outgoing ones stay here — a verifier's own page must show what it covers (JS-G2). + const outgoing = context.relationsOut; + const incoming = context.relationsIn.filter((end) => end.type !== "verifies"); + const lines: string[] = [ + "## Relations & impact (one hop)", + "", + "Every line is a one-hop neighbor over the curated graph: changing this spec touches this list plus the bindings above. Deeper reach is a script over the reader; symbol-level reach is the aspirational impact graph.", + "", + ]; + + if (context.packs.length > 0) { + const packLinks = context.packs + .map((packId) => `[\`${packId}\`](${pageHref(page, pagePathOf(packId))})`) + .join(" · "); + lines.push(`- Belongs to: ${packLinks} \`[declared]\``); + } + + for (const end of outgoing) { + lines.push(`- ${end.type} → ${linkTo(page, end)} \`[${end.claim}]\``); + } + + for (const end of incoming) { + lines.push(`- ${linkTo(page, end)} — ${end.type} → this spec \`[${end.claim}]\``); + } + + return lines.length === 4 ? [] : lines; +} + +function renderFindings(findings: readonly Finding[]): readonly string[] { + if (findings.length === 0) { + return ["## Findings", "", "None — conformance + honesty clean for this page's subject."]; + } + + const lines = ["## Findings", "", "| Severity | Check | Message | Where |", "|---|---|---|---|"]; + + for (const finding of findings) { + // Location from the structured `file`/`line` fields — a source location *recorded in the + // graph* (R2); `Primitive` nodes are line-free by design, so the line renders only when known. + const where = + finding.file === undefined + ? "—" + : `\`${finding.file}${finding.line === undefined ? "" : `:${String(finding.line)}`}\``; + lines.push( + `| ${finding.severity} | \`${finding.validatorId}\` | ${tableCell(finding.message)} | ${where} |`, + ); + } + + return lines; +} + +/* ----- pages ----- */ + +function renderSpecPage(context: SpecContext): DesignReviewPage { + const page = pagePathOf(context.id); + const kind = + context.kindDisplayLabel === undefined + ? `\`${context.specKind}\`` + : `${context.kindDisplayLabel} (\`${context.specKind}\`)`; + const lines = [ + heading(context.title, context.id), + "", + `\`${context.id}\` · ${kind} · altitude \`${context.altitude}\` · authored in [${context.file}](${sourceHref(page, context.file)}) \`[declared]\``, + "", + ...renderReadiness(context), + "", + ...renderBindings(context, page), + ...renderSections(context), + ]; + + const relations = renderRelationsAndImpact(context, page); + + if (relations.length > 0) { + lines.push("", ...relations); + } + + lines.push("", ...renderFindings(context.findings), "", "---", "", PAGE_FOOTER); + + return { path: page, content: `${lines.join("\n")}\n` }; +} + +function renderPackPage(context: PackContext, specLabel: (id: string) => string): DesignReviewPage { + const page = pagePathOf(context.id); + const lines = [ + heading(context.title, context.id), + "", + `\`${context.id}\` · Pack (the grouping / review aggregate — states no truth of its own) · authored in [${context.file}](${sourceHref(page, context.file)}) \`[declared]\``, + ]; + + if (context.framing !== undefined) { + lines.push("", `> ${context.framing}`); + } + + lines.push( + "", + "## Members", + "", + "| Spec | Kind | Altitude | Stated | Floor reached | Implementation binding | Verifier binding |", + "|---|---|---|---|---|---|---|", + ); + + for (const member of context.members) { + if (!member.resolved) { + lines.push(`| \`${member.id}\` — **unresolved** (see findings) | — | — | — | — | — | — |`); + continue; + } + + const link = `[\`${member.id}\`](${pageHref(page, pagePathOf(member.id))})`; + const present = (fact: "implemented" | "has-verifier"): string => + member.deliveryFacts.includes(fact) ? "present" : "none"; + lines.push( + `| ${link} ${tableCell(member.title ?? "")} | ${member.specKind ?? "—"} | ${member.altitude ?? "—"} | ${member.statedReadiness ?? "—"} | ${member.derivedReadiness ?? "none"} | ${present("implemented")} | ${present("has-verifier")} |`, + ); + } + + if (context.modelRefs.length > 0) { + const refs = context.modelRefs.map((ref) => specLabel(ref)).join(" · "); + lines.push("", `**Vocabulary (\`modelRefs\`):** ${refs}`); + } + + const gaps = context.verifierGaps; + + if (gaps.length > 0) { + lines.push( + "", + "## Verifier coverage gaps", + "", + "Members with no verifier binding — a surfaced absence, informative, never a gate. `ready` members are the priority slice (designed, stated done, unverified):", + "", + ); + + for (const gap of gaps) { + const stated = + gap.statedReadiness === undefined ? "" : ` (stated \`${gap.statedReadiness}\`)`; + lines.push(`- ${specLabel(gap.id)}${stated}${gap.priority ? " — **priority**" : ""}`); + } + } + + lines.push("", ...renderFindings(context.findings), "", "---", "", PAGE_FOOTER); + + return { path: page, content: `${lines.join("\n")}\n` }; +} + +function renderIndexPage(reader: Reader, specs: readonly SpecSummary[]): DesignReviewPage { + const page = "index.md"; + const packs = reader.packs(); + const findings = reader.findings(); + const lines = [ + "# Design Review", + "", + `The one generated read-only view — a pure projection of the one graph (\`graph.json\`, schema \`${reader.graph.schemaVersion}\`): ${String(reader.graph.nodes.length)} nodes · ${String(reader.graph.edges.length)} edges.`, + "", + "## Specs", + "", + "| Spec | Kind | Altitude | Stated | Floor reached | Implementation binding | Verifier binding |", + "|---|---|---|---|---|---|---|", + ]; + + for (const spec of specs) { + const link = `[\`${spec.id}\`](${pageHref(page, pagePathOf(spec.id))})`; + const present = (fact: "implemented" | "has-verifier"): string => + spec.deliveryFacts.includes(fact) ? "present" : "none"; + lines.push( + `| ${link} ${tableCell(spec.title ?? "")} | ${spec.specKind} | ${spec.altitude} | ${spec.statedReadiness} | ${spec.derivedReadiness ?? "none"} | ${present("implemented")} | ${present("has-verifier")} |`, + ); + } + + if (packs.length > 0) { + lines.push("", "## Packs", ""); + + for (const pack of packs) { + const framing = pack.framing === undefined ? "" : ` — ${pack.framing}`; + lines.push( + `- [\`${pack.id}\`](${pageHref(page, pagePathOf(pack.id))}) ${pack.title ?? ""}${framing}`, + ); + } + } + + lines.push("", ...renderFindings(findings), "", "---", "", PAGE_FOOTER); + + return { path: page, content: `${lines.join("\n")}\n` }; +} + +/** + * Renders the full Design Review off the reader. Pure and fs-free: the caller owns writing the + * pages (and owns the wholesale rewrite that keeps deleted specs from leaving stale pages). + */ +export function renderDesignReview(reader: Reader): readonly DesignReviewPage[] { + const specs = reader.specs(); + const pages: DesignReviewPage[] = [renderIndexPage(reader, specs)]; + + const specLabelFrom = (page: string) => { + return (id: string): string => { + const known = specs.find((entry) => entry.id === id); + + return known === undefined + ? `\`${id}\`` + : `[\`${id}\`](${pageHref(page, pagePathOf(id))})${known.title === undefined ? "" : ` — ${known.title}`}`; + }; + }; + + for (const spec of specs) { + const context = reader.specContext(spec.id); + + if (context !== undefined) { + pages.push(renderSpecPage(context)); + } + } + + for (const pack of reader.packs()) { + const context = reader.packContext(pack.id); + + if (context !== undefined) { + pages.push(renderPackPage(context, specLabelFrom(pagePathOf(pack.id)))); + } + } + + return pages.sort((left, right) => + left.path < right.path ? -1 : left.path > right.path ? 1 : 0, + ); +} diff --git a/src/reader/reader.ts b/src/reader/reader.ts new file mode 100644 index 0000000..685e898 --- /dev/null +++ b/src/reader/reader.ts @@ -0,0 +1,739 @@ +import { + computeDeliveryFacts, + isEnabledExampleVerify, + isResolvingTestAnchorVerify, +} from "../graph/delivery-facts.js"; +import { authoredEdgeTypes } from "../graph/schema.js"; +import type { + DeliveryFactName, + GraphClaim, + GraphEdge, + GraphEdgeType, + GraphNodeType, + GraphSchema, + PackNode, + PrimitiveNode, +} from "../graph/schema.js"; +import { SPEC_KIND_DISPLAY_LABELS } 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"; +import { buildGraphIndex } from "../validate/graph-index.js"; +import type { GraphIndex } from "../validate/graph-index.js"; +import { deriveReadiness, evaluateReadinessFloor } from "../validate/readiness-floor.js"; +import type { ReadinessFloorFailure } from "../validate/readiness-floor.js"; +import { validateGraph } from "../validate/validators.js"; + +/* ----- the plain, composable result shapes (the agent scripts these) ----- */ + +/** A spec row — the envelope plus the decoded derived data every consumer starts from. */ +export interface SpecSummary { + readonly id: string; + readonly title?: string; + readonly specKind: SpecKind; + /** Present only for ratified kinds — the "adopt the nouns" display coordinate. */ + readonly kindDisplayLabel?: string; + readonly altitude: SpecAltitude; + /** The author's statement (the envelope's `readiness`) — stated, never "claimed". */ + readonly statedReadiness: SpecReadiness; + /** The highest structurally-cleared rung (`05` §3); `undefined` when even `idea`'s clauses fail. */ + readonly derivedReadiness?: SpecReadiness; + /** Recomputed by the one derivation rule — identical to the node's stated facts on extractor output, fail-closed otherwise. */ + readonly deliveryFacts: readonly DeliveryFactName[]; + readonly file: string; + /** Pack ids the spec belongs to (derived `belongsTo` edges). */ + readonly packs: readonly string[]; +} + +export interface PackSummary { + readonly id: string; + readonly title?: string; + readonly framing?: string; + readonly file: string; + readonly modelRefs: readonly string[]; +} + +/** One decoded relation end: the edge, its `claim`, and the other endpoint's display data. */ +export interface RelationEnd { + readonly type: GraphEdgeType; + readonly claim: GraphClaim; + readonly otherId: string; + /** False when the other endpoint is absent from the graph (referential integrity's finding). */ + readonly resolved: boolean; + readonly otherNodeType?: GraphNodeType; + readonly otherTitle?: string; +} + +/** A code binding (`satisfies` from a `CodeNode`) decoded to its source location. */ +export interface ImplementationBinding { + readonly codeId: string; + readonly claim: GraphClaim; + readonly label?: string; + readonly file?: string; + readonly line?: number; +} + +/** + * A verifier decoded with its enabled-status — the cross-source join an agent hand-rolling gets + * wrong: a test anchor's `verifies` is `anchored` and is itself the binding; an example's + * declared `verifies` confers `has-verifier` only when the example is *enabled* (a resolving test + * anchor binds the example — MD-7, binding never liveness). + */ +export interface VerifierBinding { + readonly verifierId: string; + readonly via: "test-anchor" | "example"; + readonly claim: GraphClaim; + readonly enabled: boolean; + readonly label?: string; + readonly file?: string; + readonly line?: number; +} + +/** The irreducible per-spec join — what a Design Review renders and an agent starts from. */ +export interface SpecContext extends SpecSummary { + readonly sections?: SpecSections; + readonly floorFailures: 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. */ + readonly relationsIn: readonly RelationEnd[]; + readonly implementations: readonly ImplementationBinding[]; + readonly verifiers: readonly VerifierBinding[]; + /** The graph findings naming this spec (as subject or related) — the holes beside the assertions. */ + readonly findings: readonly Finding[]; +} + +export interface PackMemberSummary { + readonly id: string; + /** False when the manifest names a member absent from the graph. */ + readonly resolved: boolean; + readonly title?: string; + readonly specKind?: SpecKind; + readonly altitude?: SpecAltitude; + readonly statedReadiness?: SpecReadiness; + readonly derivedReadiness?: SpecReadiness; + readonly deliveryFacts: readonly DeliveryFactName[]; +} + +/** A member with no verifier binding — `ready` ones are the priority slice (JS-G4). */ +export interface PackVerifierGap { + readonly id: string; + readonly statedReadiness?: SpecReadiness; + readonly priority: boolean; +} + +/** The pack reviewed as a unit: members, vocabulary refs, and the verifier gaps. */ +export interface PackContext extends PackSummary { + readonly members: readonly PackMemberSummary[]; + readonly verifierGaps: readonly PackVerifierGap[]; + readonly findings: readonly Finding[]; +} + +export type ConceptMatchField = "id" | "title" | "label" | "framing" | `sections.${string}`; + +/** Where a concept search hit: the node plus the fields that matched. */ +export interface ConceptMatch { + readonly id: string; + readonly nodeType: GraphNodeType; + readonly title?: string; + readonly matchedIn: readonly ConceptMatchField[]; +} + +export interface FileNodeRef { + readonly id: string; + readonly nodeType: GraphNodeType; + readonly line?: number; +} + +/** The file→graph bridge: what the graph records at a path, and the specs reachable from it. */ +export interface FileEntry { + readonly path: string; + readonly nodes: readonly FileNodeRef[]; + /** Spec ids authored at the path, plus the targets of binding edges originating at it. */ + readonly specs: readonly string[]; +} + +/** Why a spec/pack is directly impacted: the changed file, and the binding that reached it. */ +export interface ImpactReason { + readonly file: string; + /** Present when the impact travels through a binding node at the changed file. */ + readonly throughBinding?: { + readonly id: string; + readonly edgeType: GraphEdgeType; + readonly claim: GraphClaim; + }; +} + +export interface ImpactedItem { + readonly id: string; + readonly reasons: readonly ImpactReason[]; +} + +/** An at-risk reason is the connecting edge itself — fully explicit, `claim` carried (JS-G1). */ +export interface AtRiskReason { + readonly from: string; + readonly edgeType: GraphEdgeType; + readonly to: string; + readonly claim: GraphClaim; +} + +export interface AtRiskItem { + readonly id: string; + readonly nodeType: GraphNodeType; + readonly title?: string; + readonly reasons: readonly AtRiskReason[]; +} + +/** + * File-level blast-radius (`06` §2): directly impacted specs/packs, their one-hop neighborhood + * with the connecting edges named, and — honesty about the blind spot — every changed file the + * graph records nothing at, surfaced as `coverageUnknown`, never silently dropped. The answer + * never claims exhaustive reach: deeper walks are scripts over the same shapes, and symbol-level + * reach is the aspirational impact graph. + */ +export interface BlastRadius { + readonly changedFiles: readonly string[]; + readonly impactedSpecs: readonly ImpactedItem[]; + readonly impactedPacks: readonly ImpactedItem[]; + readonly atRisk: readonly AtRiskItem[]; + readonly coverageUnknown: readonly string[]; +} + +/* ----- the reader ----- */ + +/** + * The thin typed loader behind the agent surface (`06` §3): joins, `claim` decode, delivery-fact + * recomputation, derived readiness, and the validation findings are done **once at construction**; + * accessors return plain, composable, deterministically-sorted data; nothing is persisted — a + * front door, not a store, rebuilt fresh each load. Frozen here is only the irreducible set + * (entry adapters · file-level blast-radius · the per-spec/per-pack joins); everything else — + * single-field traversals, group-bys, the maturity ladder — stays a script over `graph` and the + * flat accessors. `bySymbol` is deliberately absent, not stubbed: it rides the aspirational + * impact graph, and a method that throws would fake the capability its absence honestly hides. + * + * Not a second validation path (MD-14): `findings()` exposes `validateGraph`'s output — the one + * seam, called, never re-implemented. Exposed delivery facts are the recomputed ones (the one + * derivation rule): identical to the nodes' stated facts on extractor output by construction; + * for a foreign producer the divergence is already the delivery-facts honesty error, surfaced + * through `findings()`. + */ +export interface Reader { + readonly graph: GraphSchema; + specs(): readonly SpecSummary[]; + packs(): readonly PackSummary[]; + findings(): readonly Finding[]; + /** The grep→graph bridge from a string: deterministic substring match, never fuzzy-scored. */ + findByConcept(text: string): readonly ConceptMatch[]; + /** The grep→graph bridge from a file (extraction-root-relative POSIX path, the graph's currency). */ + byFile(path: string): FileEntry; + /** The grep→graph bridge from a changeset — file-level, `coverage-unknown` honest (`06` §2). */ + blastRadius(changedFiles: readonly string[]): BlastRadius; + specContext(id: string): SpecContext | undefined; + packContext(id: string): PackContext | undefined; +} + +function compareCodeUnits(left: string, right: string): number { + if (left < right) { + return -1; + } + + return left > right ? 1 : 0; +} + +const authoredEdgeTypeSet: ReadonlySet = new Set(authoredEdgeTypes); + +function normalizePath(path: string): string { + return path.startsWith("./") ? path.slice(2) : path; +} + +function titleOf(index: GraphIndex, id: string): string | undefined { + const node = index.nodesById.get(id); + + if (node === undefined || node.nodeType === "Anchor" || node.nodeType === "CodeNode") { + return undefined; + } + + return node.title; +} + +/** The display label, total over foreign data: an unratified kind has no display coordinate. */ +function kindDisplayLabelOf(kind: string): string | undefined { + return (SPEC_KIND_DISPLAY_LABELS as Readonly>)[kind]; +} + +function relationEnd(index: GraphIndex, edge: GraphEdge, otherId: string): RelationEnd { + const other = index.nodesById.get(otherId); + + return { + type: edge.type, + claim: edge.claim, + otherId, + resolved: other !== undefined, + ...(other === undefined ? {} : { otherNodeType: other.nodeType }), + ...(titleOf(index, otherId) === undefined ? {} : { otherTitle: titleOf(index, otherId) }), + }; +} + +function compareRelationEnds(left: RelationEnd, right: RelationEnd): number { + return compareCodeUnits(left.type, right.type) || compareCodeUnits(left.otherId, right.otherId); +} + +/** Deep scan of reified section content for a substring; returns matched section names. */ +function matchSections(sections: SpecSections | undefined, needle: string): readonly string[] { + if (sections === undefined) { + return []; + } + + const containsNeedle = (value: unknown, includeKeys: boolean): boolean => { + if (typeof value === "string") { + return value.toLowerCase().includes(needle); + } + + if (Array.isArray(value)) { + return value.some((entry) => containsNeedle(entry, includeKeys)); + } + + if (typeof value === "object" && value !== null) { + return Object.entries(value as Record).some( + ([key, entry]) => + (includeKeys && key.toLowerCase().includes(needle)) || containsNeedle(entry, includeKeys), + ); + } + + return false; + }; + + const matched: string[] = []; + + for (const [name, content] of Object.entries(sections as Record)) { + // String values are content everywhere; record keys are content only in `model.terms`, where + // the keys *are* the vocabulary (`02` §3) — structural keys (`given`, `outcome`) never match. + if (containsNeedle(content, name === "model")) { + matched.push(name); + } + } + + return matched.sort(compareCodeUnits); +} + +export function createReader(graph: GraphSchema): Reader { + const index = buildGraphIndex(graph); + const recomputedFacts = computeDeliveryFacts(graph.nodes, graph.edges); + const allFindings = validateGraph(graph).findings; + + const factsOf = (id: string): readonly DeliveryFactName[] => recomputedFacts.get(id) ?? []; + + const packsOf = (specId: string): readonly string[] => + (index.edgesByFrom.get(specId) ?? []) + .filter((edge) => edge.type === "belongsTo") + .map((edge) => edge.to) + .sort(compareCodeUnits); + + const summarize = (node: PrimitiveNode): SpecSummary => { + const derived = deriveReadiness(node, index); + const displayLabel = kindDisplayLabelOf(node.specKind); + + return { + id: node.id, + ...(node.title === undefined ? {} : { title: node.title }), + specKind: node.specKind, + ...(displayLabel === undefined ? {} : { kindDisplayLabel: displayLabel }), + altitude: node.altitude, + statedReadiness: node.readiness, + ...(derived === undefined ? {} : { derivedReadiness: derived }), + deliveryFacts: factsOf(node.id), + file: node.file, + packs: packsOf(node.id), + }; + }; + + const summarizePack = (node: PackNode): PackSummary => ({ + id: node.id, + ...(node.title === undefined ? {} : { title: node.title }), + ...(node.framing === undefined ? {} : { framing: node.framing }), + file: node.file, + modelRefs: node.modelRefs ?? [], + }); + + const primitiveNodes = (): readonly PrimitiveNode[] => + [...index.primitivesById.values()].sort((left, right) => compareCodeUnits(left.id, right.id)); + + const packNodes = (): readonly PackNode[] => + [...index.nodesById.values()] + .filter((node): node is PackNode => node.nodeType === "Pack") + .sort((left, right) => compareCodeUnits(left.id, right.id)); + + const findingsNaming = (id: string): readonly Finding[] => + allFindings.filter((finding) => finding.subjectId === id || finding.relatedId === id); + + const specContext = (id: string): SpecContext | undefined => { + const node = index.primitivesById.get(id); + + if (node === undefined) { + return undefined; + } + + const relationsOut = (index.edgesByFrom.get(id) ?? []) + .filter((edge) => edge.claim === "declared" && authoredEdgeTypeSet.has(edge.type)) + .map((edge) => relationEnd(index, edge, edge.to)) + .sort(compareRelationEnds); + + const relationsIn = (index.edgesByTo.get(id) ?? []) + .filter((edge) => edge.claim === "declared" && authoredEdgeTypeSet.has(edge.type)) + .map((edge) => relationEnd(index, edge, edge.from)) + .sort(compareRelationEnds); + + const implementations = (index.edgesByTo.get(id) ?? []) + .filter((edge) => edge.type === "satisfies") + .map((edge): ImplementationBinding => { + const source = index.nodesById.get(edge.from); + const location = + source?.nodeType === "CodeNode" + ? { + ...(source.label === undefined ? {} : { label: source.label }), + file: source.file, + ...(source.line === undefined ? {} : { line: source.line }), + } + : {}; + + return { codeId: edge.from, claim: edge.claim, ...location }; + }) + .sort((left, right) => compareCodeUnits(left.codeId, right.codeId)); + + const anchorVerified = (verifierId: string): boolean => + (index.edgesByTo.get(verifierId) ?? []).some((edge) => + isResolvingTestAnchorVerify(edge, index.nodesById), + ); + + const verifiers = (index.edgesByTo.get(id) ?? []) + .filter((edge) => edge.type === "verifies") + .map((edge): VerifierBinding => { + const source = index.nodesById.get(edge.from); + + if (source?.nodeType === "Anchor") { + return { + verifierId: edge.from, + via: "test-anchor", + claim: edge.claim, + // A resolving test anchor *is* the enabled binding (MD-7) — but only along its + // contract row: an off-contract claim confers nothing, exactly as in the derived + // facts (the shared resolving-test-anchor rule; fail closed). + enabled: isResolvingTestAnchorVerify(edge, index.nodesById), + ...(source.label === undefined ? {} : { label: source.label }), + file: source.file, + line: source.line, + }; + } + + const example = index.primitivesById.get(edge.from); + // The shared enabled-example rule (delivery-facts): one predicate decides conferral for + // the decode and the derived facts, so the two surfaces can never disagree (fail closed). + const enabled = isEnabledExampleVerify(edge, index.nodesById, anchorVerified); + + return { + verifierId: edge.from, + via: "example", + claim: edge.claim, + enabled, + ...(example?.title === undefined ? {} : { label: example.title }), + ...(example === undefined ? {} : { file: example.file }), + }; + }) + .sort((left, right) => compareCodeUnits(left.verifierId, right.verifierId)); + + return { + ...summarize(node), + ...(node.sections === undefined ? {} : { sections: node.sections }), + floorFailures: evaluateReadinessFloor(node, index), + relationsOut, + relationsIn, + implementations, + verifiers, + findings: findingsNaming(id), + }; + }; + + const packContext = (id: string): PackContext | undefined => { + const node = index.nodesById.get(id); + + if (node?.nodeType !== "Pack") { + return undefined; + } + + const members = (index.edgesByTo.get(id) ?? []) + .filter((edge) => edge.type === "belongsTo") + .map((edge): PackMemberSummary => { + const member = index.primitivesById.get(edge.from); + + if (member === undefined) { + return { id: edge.from, resolved: false, deliveryFacts: [] }; + } + + 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)); + + const verifierGaps = members + .filter((member) => member.resolved && !member.deliveryFacts.includes("has-verifier")) + .map( + (member): PackVerifierGap => ({ + id: member.id, + ...(member.statedReadiness === undefined + ? {} + : { statedReadiness: member.statedReadiness }), + priority: member.statedReadiness === "ready", + }), + ); + + return { + ...summarizePack(node), + members, + verifierGaps, + findings: findingsNaming(id), + }; + }; + + const findByConcept = (text: string): readonly ConceptMatch[] => { + const needle = text.toLowerCase().trim(); + + if (needle.length === 0) { + return []; + } + + const fieldRank: Readonly> = { + id: 0, + title: 1, + label: 2, + framing: 3, + }; + const rankOf = (field: ConceptMatchField): number => fieldRank[field] ?? 4; + const matches: { match: ConceptMatch; bestRank: number }[] = []; + + for (const node of [...index.nodesById.values()].sort((left, right) => + compareCodeUnits(left.id, right.id), + )) { + const matchedIn: ConceptMatchField[] = []; + + if (node.id.toLowerCase().includes(needle)) { + matchedIn.push("id"); + } + + if (node.nodeType === "Primitive" || node.nodeType === "Pack") { + if (node.title?.toLowerCase().includes(needle) === true) { + matchedIn.push("title"); + } + } else if (node.label?.toLowerCase().includes(needle) === true) { + matchedIn.push("label"); + } + + if (node.nodeType === "Pack" && node.framing?.toLowerCase().includes(needle) === true) { + matchedIn.push("framing"); + } + + if (node.nodeType === "Primitive") { + for (const section of matchSections(node.sections, needle)) { + matchedIn.push(`sections.${section}`); + } + } + + if (matchedIn.length === 0) { + continue; + } + + matches.push({ + match: { + id: node.id, + nodeType: node.nodeType, + ...(titleOf(index, node.id) === undefined ? {} : { title: titleOf(index, node.id) }), + matchedIn, + }, + bestRank: Math.min(...matchedIn.map(rankOf)), + }); + } + + return matches + .sort( + (left, right) => + left.bestRank - right.bestRank || compareCodeUnits(left.match.id, right.match.id), + ) + .map((entry) => entry.match); + }; + + const byFile = (path: string): FileEntry => { + const normalized = normalizePath(path); + const nodes: FileNodeRef[] = []; + const specIds = new Set(); + + for (const node of graph.nodes) { + if (node.file !== normalized) { + continue; + } + + nodes.push({ + id: node.id, + nodeType: node.nodeType, + ...(node.nodeType === "Primitive" || node.nodeType === "Pack" || node.line === undefined + ? {} + : { line: node.line }), + }); + + if (node.nodeType === "Primitive") { + specIds.add(node.id); + continue; + } + + // A binding node at the path reaches the specs its binding edges name — recorded targets, + // resolution left to referential integrity. + for (const edge of index.edgesByFrom.get(node.id) ?? []) { + if (edge.type === "satisfies" || edge.type === "verifies") { + specIds.add(edge.to); + } + } + } + + return { + path: normalized, + nodes: nodes.sort((left, right) => compareCodeUnits(left.id, right.id)), + specs: [...specIds].sort(compareCodeUnits), + }; + }; + + const blastRadius = (changedFiles: readonly string[]): BlastRadius => { + const normalized = [...new Set(changedFiles.map(normalizePath))].sort(compareCodeUnits); + const impactedSpecReasons = new Map(); + const impactedPackReasons = new Map(); + const coverageUnknown: string[] = []; + + const appendReason = ( + map: Map, + id: string, + reason: ImpactReason, + ): void => { + const list = map.get(id) ?? []; + list.push(reason); + map.set(id, list); + }; + + for (const file of normalized) { + const entry = byFile(file); + + if (entry.nodes.length === 0) { + // The honest blind spot (`06` §2): a changed file the graph records nothing at is named, + // never silently dropped — file-level reach must not read as exhaustive. + coverageUnknown.push(file); + continue; + } + + for (const ref of entry.nodes) { + if (ref.nodeType === "Primitive") { + appendReason(impactedSpecReasons, ref.id, { file }); + continue; + } + + if (ref.nodeType === "Pack") { + appendReason(impactedPackReasons, ref.id, { file }); + continue; + } + + for (const edge of index.edgesByFrom.get(ref.id) ?? []) { + if (edge.type === "satisfies" || edge.type === "verifies") { + appendReason(impactedSpecReasons, edge.to, { + file, + throughBinding: { id: ref.id, edgeType: edge.type, claim: edge.claim }, + }); + } + } + } + } + + const changedFileSet = new Set(normalized); + const impactedIds = new Set([...impactedSpecReasons.keys(), ...impactedPackReasons.keys()]); + const atRiskReasons = new Map(); + + // One explicit hop from every impacted node, any edge type and direction, `claim` carried — + // a node whose own file changed is the change, not the risk, and is excluded. + for (const impactedId of impactedIds) { + const incident = [ + ...(index.edgesByFrom.get(impactedId) ?? []), + ...(index.edgesByTo.get(impactedId) ?? []), + ]; + + for (const edge of incident) { + const otherId = edge.from === impactedId ? edge.to : edge.from; + const other = index.nodesById.get(otherId); + + if (other === undefined || impactedIds.has(otherId) || changedFileSet.has(other.file)) { + continue; + } + + const list = atRiskReasons.get(otherId) ?? []; + list.push({ from: edge.from, edgeType: edge.type, to: edge.to, claim: edge.claim }); + atRiskReasons.set(otherId, list); + } + } + + const toImpacted = (map: Map): readonly ImpactedItem[] => + [...map.entries()] + .map(([id, reasons]) => ({ + id, + reasons: reasons.sort( + (left, right) => + compareCodeUnits(left.file, right.file) || + compareCodeUnits(left.throughBinding?.id ?? "", right.throughBinding?.id ?? ""), + ), + })) + .sort((left, right) => compareCodeUnits(left.id, right.id)); + + const atRisk = [...atRiskReasons.entries()] + .map(([id, reasons]): AtRiskItem => { + const node = index.nodesById.get(id); + const title = titleOf(index, id); + + return { + id, + nodeType: node?.nodeType ?? "Primitive", + ...(title === undefined ? {} : { title }), + reasons: reasons.sort( + (left, right) => + compareCodeUnits(left.from, right.from) || + compareCodeUnits(left.edgeType, right.edgeType) || + compareCodeUnits(left.to, right.to), + ), + }; + }) + .sort((left, right) => compareCodeUnits(left.id, right.id)); + + return { + changedFiles: normalized, + impactedSpecs: toImpacted(impactedSpecReasons), + impactedPacks: toImpacted(impactedPackReasons), + atRisk, + coverageUnknown, + }; + }; + + return { + graph, + specs: () => primitiveNodes().map(summarize), + packs: () => packNodes().map(summarizePack), + findings: () => allFindings, + findByConcept, + byFile, + blastRadius, + specContext, + packContext, + }; +} diff --git a/src/validate/authored-model.ts b/src/validate/authored-model.ts deleted file mode 100644 index 894be07..0000000 --- a/src/validate/authored-model.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Anchor } from "../model/anchors.js"; -import type { Pack } from "../model/pack.js"; -import type { Spec } from "../model/spec.js"; - -/** - * Pre-graph authored-layer DTO — the stand-in harness until the extractor lands (one validation - * path, MD-14): not persisted, not a graph, and never a second public validation seam (not the - * Slice 3 gate). - */ -export interface AuthoredModel { - readonly specs: readonly Spec[]; - readonly packs: readonly Pack[]; - readonly anchors: readonly Anchor[]; -} diff --git a/src/validate/contracts.ts b/src/validate/contracts.ts index 73fdfbc..77b7d9b 100644 --- a/src/validate/contracts.ts +++ b/src/validate/contracts.ts @@ -1,3 +1,5 @@ +import type { GraphSchema } from "../graph/schema.js"; + export const validatorFamilies = ["conformance", "honesty"] as const; export type ValidatorFamily = (typeof validatorFamilies)[number]; @@ -12,6 +14,13 @@ export interface Finding { readonly subjectId?: string; readonly relatedId?: string; readonly path?: string; + /** + * Source location, carried by producers that read files (the extractor). Additive (L9) so the + * one diagnostic currency stays one — no parallel extraction-report shape. Root-relative POSIX + * path; 1-based line. + */ + readonly file?: string; + readonly line?: number; } export interface ValidationReport { @@ -25,8 +34,8 @@ export interface ValidationReport { readonly findings: readonly Finding[]; } -export interface Validator { +export interface Validator { readonly id: string; readonly family: ValidatorFamily; - validate(model: TModel): ValidationReport; + validate(input: TInput): ValidationReport; } diff --git a/src/validate/graph-index.ts b/src/validate/graph-index.ts new file mode 100644 index 0000000..2a8ebe2 --- /dev/null +++ b/src/validate/graph-index.ts @@ -0,0 +1,50 @@ +import type { GraphEdge, GraphNode, GraphSchema, PrimitiveNode } from "../graph/schema.js"; + +/** + * The indexed view of the one graph the validators read — built once per `validateGraph` run. + * Purely derived lookup structure over the flat node/edge arrays; holds nothing the graph does + * not (never a second store). + */ +export interface GraphIndex { + readonly nodesById: ReadonlyMap; + readonly primitivesById: ReadonlyMap; + readonly edgesByFrom: ReadonlyMap; + readonly edgesByTo: ReadonlyMap; +} + +function appendEdge(map: Map, key: string, edge: GraphEdge): void { + const list = map.get(key); + + if (list === undefined) { + map.set(key, [edge]); + return; + } + + list.push(edge); +} + +export function buildGraphIndex(graph: GraphSchema): GraphIndex { + const nodesById = new Map(); + const primitivesById = new Map(); + const edgesByFrom = new Map(); + const edgesByTo = new Map(); + + for (const node of graph.nodes) { + // Duplicate node ids cannot be keyed; the first carrier wins here and the duplicate-ids + // validator reports the ambiguity loudly (L2) — the index never auto-resolves it. + if (!nodesById.has(node.id)) { + nodesById.set(node.id, node); + } + + if (node.nodeType === "Primitive" && !primitivesById.has(node.id)) { + primitivesById.set(node.id, node); + } + } + + for (const edge of graph.edges) { + appendEdge(edgesByFrom, edge.from, edge); + appendEdge(edgesByTo, edge.to, edge); + } + + return { nodesById, primitivesById, edgesByFrom, edgesByTo }; +} diff --git a/src/validate/readiness-floor.ts b/src/validate/readiness-floor.ts index d09ca48..69dee23 100644 --- a/src/validate/readiness-floor.ts +++ b/src/validate/readiness-floor.ts @@ -1,38 +1,34 @@ -import { SPEC_READINESS } from "../model/descriptors.js"; +import { SPEC_KINDS, SPEC_READINESS } from "../model/descriptors.js"; import type { SpecKind, SpecReadiness } from "../model/descriptors.js"; -import type { Spec } from "../model/spec.js"; -import type { AuthoredModel } from "./authored-model.js"; +import type { SpecSectionName } from "../model/sections.js"; +import { authoredEdgeTypes } from "../graph/schema.js"; +import type { GraphEdge, PrimitiveNode } from "../graph/schema.js"; +import type { GraphIndex } from "./graph-index.js"; /** - * The readiness floor — the single source of truth (MD-13), mirroring `05` §3 row-for-row: kind-blind - * structural clauses (`readinessFloors`) plus one kind-conditional evidence clause read from the - * per-kind evidence table (`kindEvidence`, MD-12) — `scoped` requires the kind's natural evidence - * *present* (prose acceptable), `defined` requires it *complete* where the kind defines a stronger - * form. The clause-id union is derived from the table, the evaluator is one generic loop, and - * evidence predicates take `(spec, model)` because promotion-neutrality (MD-10) needs the authored - * model to count refining children. + * The readiness floor — the single source of truth (MD-13), mirroring `05` §3 row-for-row: + * kind-blind structural clauses (`readinessFloors`) plus one kind-conditional evidence clause read + * from the per-kind evidence table (`kindEvidence`, MD-12) — `scoped` requires the kind's natural + * evidence *present* (prose acceptable), `defined` requires it *complete* where the kind defines a + * stronger form. The clause-id union is derived from the table, the evaluator is one generic loop, + * and predicates read the one graph (one validation path, MD-14): the spec is a `Primitive` node, + * relations are its declared edges, and promotion-neutrality (MD-10) walks `refines` edges into + * the children's nodes. + * + * Evidence predicates are total over arbitrary section content: a graph node's sections are + * statically-reified value data, never a typechecked instance, so a malformed shape reads as + * absent evidence — typed sections (MD-11) stay the authoring-time guardrail, the floor never + * throws. */ -export type ReadinessPredicate = (spec: Spec, model: AuthoredModel) => boolean; +export type ReadinessPredicate = (node: PrimitiveNode, index: GraphIndex) => boolean; -export interface ActiveReadinessClause { +export interface ReadinessClause { readonly id: string; readonly description: string; readonly predicate: ReadinessPredicate; } -/** - * Graph-shaped clauses — they resolve across the one graph (one validation path, MD-14; executes - * Slice 1/3) and carry no pre-graph predicate, so the pre-graph evaluator skips them. - */ -export interface GraphReadinessClause { - readonly id: string; - readonly description: string; - readonly evaluatedOver: "graph"; -} - -export type ReadinessClause = ActiveReadinessClause | GraphReadinessClause; - export interface ReadinessFloor { readonly readiness: SpecReadiness; readonly clauses: readonly ReadinessClause[]; @@ -50,61 +46,95 @@ export interface KindEvidenceRow { readonly defined: KindEvidenceCell; } -/* ----- the named predicate library ----- */ +/* ----- defensive value access (reified content, not typechecked instances) ----- */ + +function asRecord(value: unknown): Record | undefined { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return value as Record; + } -function hasNonEmptyString(value: unknown): boolean { + return undefined; +} + +function asArray(value: unknown): readonly unknown[] | undefined { + return Array.isArray(value) ? (value as readonly unknown[]) : undefined; +} + +function isNonEmptyString(value: unknown): boolean { return typeof value === "string" && value.trim().length > 0; } -function hasEntries(value: readonly unknown[] | undefined): boolean { - return (value?.length ?? 0) > 0; +function hasEntries(value: unknown): boolean { + return (asArray(value)?.length ?? 0) > 0; } -function hasSpecId(spec: Spec): boolean { - return hasNonEmptyString(spec.id); +function sectionOf(node: PrimitiveNode, name: SpecSectionName): unknown { + return node.sections?.[name]; } -function hasTitle(spec: Spec): boolean { - return hasNonEmptyString(spec.title); +/* ----- the named predicate library ----- */ + +function hasSpecId(node: PrimitiveNode): boolean { + return isNonEmptyString(node.id); } -function hasKind(spec: Spec): boolean { - return hasNonEmptyString(spec.kind); +function hasTitle(node: PrimitiveNode): boolean { + return isNonEmptyString(node.title); } -function hasAltitude(spec: Spec): boolean { - return hasNonEmptyString(spec.altitude); +function hasKind(node: PrimitiveNode): boolean { + return isNonEmptyString(node.specKind); } -function hasIntentOutcome(spec: Spec): boolean { - return hasNonEmptyString(spec.intent?.outcome); +function hasAltitude(node: PrimitiveNode): boolean { + return isNonEmptyString(node.altitude); } -function hasParentRelation(spec: Spec): boolean { - return (spec.relations ?? []).some((relation) => relation.type === "refines"); +function hasIntentOutcome(node: PrimitiveNode): boolean { + return isNonEmptyString(asRecord(sectionOf(node, "intent"))?.outcome); } -function hasIntentOutcomeOrParentRelation(spec: Spec): boolean { - return hasIntentOutcome(spec) || hasParentRelation(spec); +const authoredEdgeTypeSet: ReadonlySet = new Set(authoredEdgeTypes); + +/** + * The spec's authored relations on the graph: declared edges of an authored relation type. + * `belongsTo` is derived from the pack manifest and never counts as a relation the spec declared. + */ +function declaredRelationEdges(node: PrimitiveNode, index: GraphIndex): readonly GraphEdge[] { + return (index.edgesByFrom.get(node.id) ?? []).filter( + (edge) => edge.claim === "declared" && authoredEdgeTypeSet.has(edge.type), + ); } -function hasAtLeastOneRelation(spec: Spec): boolean { - return hasEntries(spec.relations); +function hasParentRelation(node: PrimitiveNode, index: GraphIndex): boolean { + return declaredRelationEdges(node, index).some((edge) => edge.type === "refines"); +} + +function hasIntentOutcomeOrParentRelation(node: PrimitiveNode, index: GraphIndex): boolean { + return hasIntentOutcome(node) || hasParentRelation(node, index); +} + +function hasAtLeastOneRelation(node: PrimitiveNode, index: GraphIndex): boolean { + return declaredRelationEdges(node, index).length > 0; } /** Blocking open questions live in `intent.openQuestions` (MD-9); only an entry flagged `blocking: true` blocks. */ -function hasNoBlockingOpenQuestions(spec: Spec): boolean { - return !(spec.intent?.openQuestions ?? []).some( - (entry) => typeof entry !== "string" && entry.blocking === true, - ); +function hasNoBlockingOpenQuestions(node: PrimitiveNode): boolean { + const openQuestions = asArray(asRecord(sectionOf(node, "intent"))?.openQuestions) ?? []; + + return !openQuestions.some((entry) => asRecord(entry)?.blocking === true); +} + +function behaviorOf(node: PrimitiveNode): Record | undefined { + return asRecord(sectionOf(node, "behavior")); } -function hasInlineRulesOrExamples(spec: Spec): boolean { - return hasEntries(spec.behavior?.rules) || hasEntries(spec.behavior?.examples); +function hasInlineRulesOrExamples(node: PrimitiveNode): boolean { + return hasEntries(behaviorOf(node)?.rules) || hasEntries(behaviorOf(node)?.examples); } -function hasInlineBehaviorEvidence(spec: Spec): boolean { - return hasInlineRulesOrExamples(spec) || hasEntries(spec.behavior?.flows); +function hasInlineBehaviorEvidence(node: PrimitiveNode): boolean { + return hasInlineRulesOrExamples(node) || hasEntries(behaviorOf(node)?.flows); } /** Typed accessor into the evidence table — returns the cell at its declared (2-arg) predicate type. */ @@ -115,74 +145,128 @@ function evidenceCell(kind: SpecKind, rung: keyof KindEvidenceRow): KindEvidence /** * Promotion-neutral evidence (MD-10/MD-12), bounded by MD-16: a promoted child counts wherever an * inline entry would — but only when the child itself carries its kind's evidence. Promotion moves - * content out (MD-10), so an empty stub child is not a promotion and never clears a parent's floor. + * content out (MD-10), so an empty stub child is not a promotion and never clears a parent's + * floor. On the graph, a promoted child is a `rule`/`example` Primitive whose declared `refines` + * edge targets this spec. */ -function hasPromotedRuleOrExampleEvidence(spec: Spec, model: AuthoredModel): boolean { - return model.specs.some( - (child) => - (child.kind === "rule" || child.kind === "example") && - evidenceCell(child.kind, "scoped").predicate(child, model) && - (child.relations ?? []).some( - (relation) => relation.type === "refines" && relation.target === spec.id, - ), - ); +function hasPromotedRuleOrExampleEvidence(node: PrimitiveNode, index: GraphIndex): boolean { + return (index.edgesByTo.get(node.id) ?? []).some((edge) => { + if (edge.type !== "refines" || edge.claim !== "declared") { + return false; + } + + const child = index.primitivesById.get(edge.from); + + return ( + child !== undefined && + (child.specKind === "rule" || child.specKind === "example") && + evidenceCell(child.specKind, "scoped").predicate(child, index) + ); + }); } /** * The constrainedBy evidence slot is the promoted twin of the inline `constraints` section (`02` §3 - * duality), so it counts only when the edge resolves in the model to a `constraint`-kind spec that + * duality), so it counts only when the edge resolves in the graph to a `constraint`-kind spec that * carries its own evidence (MD-16). A dangling or wrong-kind target is not evidence. */ -function hasConstrainedByConstraintEvidence(spec: Spec, model: AuthoredModel): boolean { - const targets = new Set( - (spec.relations ?? []) - .filter((relation) => relation.type === "constrainedBy") - .map((relation) => relation.target), - ); +function hasConstrainedByConstraintEvidence(node: PrimitiveNode, index: GraphIndex): boolean { + return declaredRelationEdges(node, index).some((edge) => { + if (edge.type !== "constrainedBy") { + return false; + } - if (targets.size === 0) { - return false; - } + const target = index.primitivesById.get(edge.to); - return model.specs.some( - (candidate) => - targets.has(candidate.id) && - candidate.kind === "constraint" && - evidenceCell("constraint", "scoped").predicate(candidate, model), - ); + return ( + target?.specKind === "constraint" && + evidenceCell("constraint", "scoped").predicate(target, index) + ); + }); } -function hasStructuredExampleEntry(spec: Spec): boolean { - return (spec.behavior?.examples ?? []).some( - (entry) => - typeof entry !== "string" && - entry.given.length > 0 && - entry.when.length > 0 && - entry.then.length > 0, - ); +function hasStructuredExampleEntry(node: PrimitiveNode): boolean { + return (asArray(behaviorOf(node)?.examples) ?? []).some((entry) => { + const structured = asRecord(entry); + + return ( + structured !== undefined && + hasEntries(structured.given) && + hasEntries(structured.when) && + hasEntries(structured.then) + ); + }); } -function hasConstraintEntries(spec: Spec): boolean { - return hasEntries(spec.constraints); +function hasConstraintEntries(node: PrimitiveNode): boolean { + return hasEntries(sectionOf(node, "constraints")); } -function constraintTargetsAreMachineReadable(spec: Spec): boolean { - return ( - hasConstraintEntries(spec) && - (spec.constraints ?? []).every((entry) => hasNonEmptyString(entry.target)) - ); +function constraintTargetsAreMachineReadable(node: PrimitiveNode): boolean { + const entries = asArray(sectionOf(node, "constraints")) ?? []; + + return entries.length > 0 && entries.every((entry) => isNonEmptyString(asRecord(entry)?.target)); } -function hasModelTerms(spec: Spec): boolean { - return spec.model?.terms !== undefined && Object.keys(spec.model.terms).length > 0; +function hasModelTerms(node: PrimitiveNode): boolean { + const terms = asRecord(asRecord(sectionOf(node, "model"))?.terms); + + return terms !== undefined && Object.keys(terms).length > 0; +} + +function hasDecisionSection(node: PrimitiveNode): boolean { + const decision = asRecord(sectionOf(node, "decision")); + + return decision !== undefined && Object.keys(decision).length > 0; +} + +function hasWrittenDecision(node: PrimitiveNode): boolean { + return isNonEmptyString(asRecord(sectionOf(node, "decision"))?.decision); } -function hasDecisionSection(spec: Spec): boolean { - return spec.decision !== undefined && Object.keys(spec.decision).length > 0; +/* ----- the graph-shaped ready clauses ----- */ + +function allRelationsResolve(node: PrimitiveNode, index: GraphIndex): boolean { + return declaredRelationEdges(node, index).every((edge) => index.nodesById.has(edge.to)); } -function hasWrittenDecision(spec: Spec): boolean { - return hasNonEmptyString(spec.decision?.decision); +const definedIndex = SPEC_READINESS.indexOf("defined"); + +/** + * Evaluates resolving targets only — an unresolved target is `all-relations-resolve`'s failure, + * never a second floor failure. A target resolving to a non-`Primitive` node is not a spec at + * `defined` and fails (the edge contract makes that shape a conformance error besides). + */ +function dependsOnAndRefinesTargetsAreDefined(node: PrimitiveNode, index: GraphIndex): boolean { + return declaredRelationEdges(node, index).every((edge) => { + if (edge.type !== "dependsOn" && edge.type !== "refines") { + 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; + }); +} + +/** + * Every binding edge naming this spec originates from a binding node present in the graph — so + * `implemented` stays derivable from a real binding (the delivery-fact computation trusts edges). + * Extractor output satisfies this by construction (an anchor and its edge derive together); the + * clause has teeth for any other graph producer, and a regression that emitted an edge without + * its node would fail here. + */ +function anchorsResolve(node: PrimitiveNode, index: GraphIndex): boolean { + return (index.edgesByTo.get(node.id) ?? []).every((edge) => { + const isBindingEdge = + edge.type === "satisfies" || (edge.type === "verifies" && edge.claim === "anchored"); + + return !isBindingEdge || index.nodesById.has(edge.from); + }); } /* ----- the per-kind evidence table (MD-12; mirrors `05` §3) ----- */ @@ -191,17 +275,17 @@ const behaviorFamilyEvidence: KindEvidenceRow = { scoped: { description: "rules, examples, flows, or constraints — inline, or promoted (a refining rule/example child, or a constrainedBy-linked constraint, each carrying its own evidence)", - predicate: (spec, model) => - hasInlineBehaviorEvidence(spec) || - hasConstraintEntries(spec) || - hasConstrainedByConstraintEvidence(spec, model) || - hasPromotedRuleOrExampleEvidence(spec, model), + predicate: (node, index) => + hasInlineBehaviorEvidence(node) || + hasConstraintEntries(node) || + hasConstrainedByConstraintEvidence(node, index) || + hasPromotedRuleOrExampleEvidence(node, index), }, defined: { description: "rules and/or examples — inline or promoted children carrying their evidence; constraints alone no longer suffice", - predicate: (spec, model) => - hasInlineRulesOrExamples(spec) || hasPromotedRuleOrExampleEvidence(spec, model), + predicate: (node, index) => + hasInlineRulesOrExamples(node) || hasPromotedRuleOrExampleEvidence(node, index), }, }; @@ -211,7 +295,7 @@ export const kindEvidence = { example: { scoped: { description: "an examples entry (prose acceptable)", - predicate: (spec) => hasEntries(spec.behavior?.examples), + predicate: (node) => hasEntries(behaviorOf(node)?.examples), }, defined: { description: "at least one structured { given, when, then } examples entry", @@ -221,11 +305,11 @@ export const kindEvidence = { rule: { scoped: { description: "its statement in behavior.rules", - predicate: (spec) => hasEntries(spec.behavior?.rules), + predicate: (node) => hasEntries(behaviorOf(node)?.rules), }, defined: { description: "its statement in behavior.rules — a rule's content is its statement", - predicate: (spec) => hasEntries(spec.behavior?.rules), + predicate: (node) => hasEntries(behaviorOf(node)?.rules), }, }, constraint: { @@ -264,11 +348,11 @@ export const kindEvidence = { contract: behaviorFamilyEvidence, } as const satisfies Record; -const kindEvidencePresent: ReadinessPredicate = (spec, model) => - evidenceCell(spec.kind, "scoped").predicate(spec, model); +const kindEvidencePresent: ReadinessPredicate = (node, index) => + evidenceCell(node.specKind, "scoped").predicate(node, index); -const kindEvidenceComplete: ReadinessPredicate = (spec, model) => - evidenceCell(spec.kind, "defined").predicate(spec, model); +const kindEvidenceComplete: ReadinessPredicate = (node, index) => + evidenceCell(node.specKind, "defined").predicate(node, index); /* ----- the kind-blind structural clauses (mirrors `05` §3) ----- */ @@ -344,17 +428,17 @@ export const readinessFloors = { { id: "all-relations-resolve", description: "All authored relations resolve to known targets.", - evaluatedOver: "graph", + predicate: allRelationsResolve, }, { id: "depends-on-and-refines-targets-are-defined", description: "Every dependsOn and refines target is at least defined.", - evaluatedOver: "graph", + predicate: dependsOnAndRefinesTargetsAreDefined, }, { id: "anchors-resolve", - description: "Any authored anchors present resolve.", - evaluatedOver: "graph", + description: "Any anchors present resolve.", + predicate: anchorsResolve, }, ], }, @@ -368,25 +452,32 @@ export interface ReadinessFloorFailure { readonly description: string; } +const ratifiedKinds: ReadonlySet = new Set(SPEC_KINDS); +const ratifiedReadiness: ReadonlySet = new Set(SPEC_READINESS); + /** * The one generic evaluator (MD-13): floors are cumulative, so every clause at or below the stated - * rung must hold; graph-shaped clauses carry no pre-graph predicate and are skipped until the - * extractor lands (Slice 1/3). + * rung must hold. Evaluates a `Primitive` node against the indexed graph (one validation path, + * MD-14). */ export function evaluateReadinessFloor( - spec: Spec, - model: AuthoredModel, + node: PrimitiveNode, + index: GraphIndex, ): readonly ReadinessFloorFailure[] { - const statedIndex = SPEC_READINESS.indexOf(spec.readiness); + // Foreign graph data can carry unratified strings in the typed descriptor slots; those are the + // descriptor conformance errors (`05` §2 check 3 — validateGraph fails closed), and the + // evaluator stays total: over an unratified kind or readiness 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)) { + return []; + } + + const statedIndex = SPEC_READINESS.indexOf(node.readiness); const failures: ReadinessFloorFailure[] = []; for (const readiness of SPEC_READINESS.slice(0, statedIndex + 1)) { for (const clause of readinessFloors[readiness].clauses) { - if ("evaluatedOver" in clause) { - continue; - } - - if (clause.predicate(spec, model)) { + if (clause.predicate(node, index)) { continue; } @@ -396,3 +487,35 @@ export function evaluateReadinessFloor( return failures; } + +/** + * Derived readiness (`05` §3): the highest rung whose cumulative clauses all pass — what the spec + * structurally *is*, beside what the author *states*. Same table, same predicates, no second + * floor (MD-13); the stated rung is never consulted. Returns `undefined` when even the `idea` + * clauses fail. Total over foreign data: an unratified `specKind` cannot dereference the evidence + * table, so no rung derives — the descriptor conformance error owns that finding, exactly as in + * the evaluator. The divergence reading: derived *below* stated is the honesty signal the floor + * check fails on; derived at-or-above stated is ordinary information — the floor is never a + * quota, and a spec may honestly state less than it clears. + */ +export function deriveReadiness(node: PrimitiveNode, index: GraphIndex): SpecReadiness | undefined { + if (!ratifiedKinds.has(node.specKind)) { + return undefined; + } + + let reached: SpecReadiness | undefined; + + for (const readiness of SPEC_READINESS) { + const cleared = readinessFloors[readiness].clauses.every((clause) => + clause.predicate(node, index), + ); + + if (!cleared) { + break; + } + + reached = readiness; + } + + return reached; +} diff --git a/src/validate/validators.ts b/src/validate/validators.ts index e71e898..38cfc94 100644 --- a/src/validate/validators.ts +++ b/src/validate/validators.ts @@ -1,236 +1,915 @@ -import { deliveryFactNames } from "../graph/schema.js"; -import type { AnchorId, PackId, SpecId } from "../ids.js"; +import { computeDeliveryFacts, isResolvingTestAnchorVerify } from "../graph/delivery-facts.js"; +import { deliveryFactNames, graphClaims, graphEdgeTypes, graphNodeTypes } from "../graph/schema.js"; +import type { + DeliveryFactName, + GraphClaim, + GraphEdge, + GraphNode, + GraphNodeType, + GraphSchema, + PackNode, + PrimitiveNode, +} from "../graph/schema.js"; +import { SPEC_ALTITUDES, SPEC_KINDS, SPEC_READINESS } from "../model/descriptors.js"; import { SPEC_SECTION_NAMES } from "../model/sections.js"; -import type { AuthoredModel } from "./authored-model.js"; -import type { Finding, ValidationReport, ValidatorFamily } from "./contracts.js"; +import { buildGraphIndex } from "./graph-index.js"; +import type { GraphIndex } from "./graph-index.js"; +import type { Finding, Severity, ValidationReport, ValidatorFamily } from "./contracts.js"; import { evaluateReadinessFloor } from "./readiness-floor.js"; -const duplicateIdsValidatorId = "conformance/duplicate-ids"; -const danglingReferencesValidatorId = "conformance/dangling-references"; -const authoringShapeValidatorId = "honesty/authoring-shape"; -const readinessFloorValidatorId = "honesty/readiness-floor"; -const authoredModelValidatorId = "authored-model"; - -type AuthoredId = SpecId | PackId | AnchorId; - -function createReport( - validatorId: string, - findings: readonly Finding[], - family?: ValidatorFamily, -): ValidationReport { - if (family === undefined) { - return { validatorId, findings }; - } - - return { validatorId, family, findings }; -} - -function createFinding( - validatorId: string, - family: ValidatorFamily, - message: string, - subjectId?: string, - relatedId?: string, - path?: string, -): Finding { - return { - validatorId, - family, - severity: "error", - message, - subjectId, - relatedId, - path, - }; +/** + * The MVP graph validators (`05` §2), keyed to the one graph — the sole public validation seam + * (one validation path, MD-14): source → extract → graph → checks. Errors fail the build; a + * `gap` or `orphan` informs as a warning, never a gate. Ids are referenced typo-safely, mirroring + * `extractFindingIds`. + */ +export const graphValidatorIds = { + referentialIntegrity: "conformance/referential-integrity", + duplicateIds: "conformance/duplicate-ids", + claimSeparation: "conformance/claim-separation", + verifiesLinkage: "conformance/verifies-linkage", + packCoherence: "conformance/pack-coherence", + orphans: "conformance/orphans", + authoringShape: "honesty/authoring-shape", + deliveryFacts: "honesty/delivery-facts", + readinessFloor: "honesty/readiness-floor", + gaps: "honesty/gaps", +} as const; + +/** The aggregate report id; it spans both check families, so it carries no single family. */ +export const graphReportId = "graph"; + +interface FindingSeed { + readonly validatorId: string; + readonly family: ValidatorFamily; + readonly severity: Severity; + readonly message: string; + readonly subjectId?: string; + readonly relatedId?: string; + readonly path?: string; + readonly file?: string; } -function collectAuthoredIds(model: AuthoredModel): readonly AuthoredId[] { - return [ - ...model.specs.map((entry) => entry.id), - ...model.packs.map((entry) => entry.id), - ...model.anchors.map((entry) => entry.id), - ]; +function createFinding(seed: FindingSeed): Finding { + return seed; +} + +function fileOf(index: GraphIndex, id: string): string | undefined { + return index.nodesById.get(id)?.file; +} + +/* ----- did-you-mean (referential integrity's suggestion) ----- */ + +function boundedEditDistance(left: string, right: string, max: number): number | undefined { + if (Math.abs(left.length - right.length) > max) { + return undefined; + } + + let previous = Array.from({ length: right.length + 1 }, (_, column) => column); + + for (let row = 1; row <= left.length; row += 1) { + const current = [row]; + let rowMinimum = row; + + for (let column = 1; column <= right.length; column += 1) { + const deletion = (previous[column] ?? 0) + 1; + const insertion = (current[column - 1] ?? 0) + 1; + const substitution = + (previous[column - 1] ?? 0) + (left[row - 1] === right[column - 1] ? 0 : 1); + const cost = Math.min(deletion, insertion, substitution); + current.push(cost); + rowMinimum = Math.min(rowMinimum, cost); + } + + if (rowMinimum > max) { + return undefined; + } + + previous = current; + } + + const distance = previous[right.length] ?? 0; + + return distance <= max ? distance : undefined; +} + +/** + * The "did you mean …?" candidate for a dangling reference: the unique nearest node id within + * edit distance 2. A tie yields no suggestion — picking a winner would be the silent + * auto-resolution L2 forbids. + */ +function suggestNearestId(missingId: string, index: GraphIndex): string | undefined { + let best: string | undefined; + let bestDistance = 3; + let tied = false; + + for (const candidate of index.nodesById.keys()) { + const distance = boundedEditDistance(missingId, candidate, 2); + + if (distance === undefined) { + continue; + } + + if (distance < bestDistance) { + best = candidate; + bestDistance = distance; + tied = false; + } else if (distance === bestDistance) { + tied = true; + } + } + + return tied ? undefined : best; +} + +function describeMissingTarget(missingId: string, index: GraphIndex): string { + const suggestion = suggestNearestId(missingId, index); + + return suggestion === undefined ? "" : ` Did you mean "${suggestion}"?`; } -export function validateDuplicateIds(model: AuthoredModel): ValidationReport { - const seen = new Set(); - const emitted = new Set(); +/* ----- conformance/referential-integrity (`05` §2 check 1) ----- */ + +function checkReferentialIntegrity(graph: GraphSchema, index: GraphIndex): readonly Finding[] { const findings: Finding[] = []; - for (const id of collectAuthoredIds(model)) { - if (!seen.has(id)) { - seen.add(id); + for (const edge of graph.edges) { + if (!index.nodesById.has(edge.from)) { + findings.push( + createFinding({ + validatorId: graphValidatorIds.referentialIntegrity, + family: "conformance", + severity: "error", + message: `Edge "${edge.from}" → (${edge.type}) → "${edge.to}" originates from a node absent from the graph.${describeMissingTarget(edge.from, index)}`, + subjectId: edge.from, + relatedId: edge.to, + }), + ); + } + + if (!index.nodesById.has(edge.to)) { + findings.push( + createFinding({ + validatorId: graphValidatorIds.referentialIntegrity, + family: "conformance", + severity: "error", + message: `Reference from "${edge.from}" via "${edge.type}" points to missing target "${edge.to}".${describeMissingTarget(edge.to, index)}`, + subjectId: edge.from, + relatedId: edge.to, + file: fileOf(index, edge.from), + }), + ); + } + } + + for (const node of graph.nodes) { + if (node.nodeType !== "Pack") { continue; } - if (emitted.has(id)) { + for (const [position, target] of (node.modelRefs ?? []).entries()) { + if (index.nodesById.has(target)) { + continue; + } + + findings.push( + createFinding({ + validatorId: graphValidatorIds.referentialIntegrity, + family: "conformance", + severity: "error", + message: `Pack "${node.id}" modelRefs[${String(position)}] points to missing target "${target}".${describeMissingTarget(target, index)}`, + subjectId: node.id, + relatedId: target, + path: `modelRefs[${String(position)}]`, + file: node.file, + }), + ); + } + } + + return findings; +} + +/* ----- conformance/duplicate-ids (`05` §2 check 2) ----- */ + +/** + * The graph backstop for L2: the extractor reports duplicate authored ids per site + * (`extract/duplicate-id`) and excludes the carriers before derivation, so over extractor output + * this never fires — it has teeth for any other graph producer. + */ +function checkDuplicateIds(graph: GraphSchema): readonly Finding[] { + const carriers = new Map(); + + for (const node of graph.nodes) { + carriers.set(node.id, (carriers.get(node.id) ?? 0) + 1); + } + + const findings: Finding[] = []; + + for (const node of graph.nodes) { + const count = carriers.get(node.id) ?? 0; + + if (count < 2) { continue; } - emitted.add(id); findings.push( - createFinding(duplicateIdsValidatorId, "conformance", `Duplicate authored id "${id}".`, id), + createFinding({ + validatorId: graphValidatorIds.duplicateIds, + family: "conformance", + severity: "error", + message: `Id "${node.id}" is carried by ${String(count)} nodes (ambiguity is loud, L2) — the graph cannot be keyed on it.`, + subjectId: node.id, + file: node.file, + }), ); } - return createReport(duplicateIdsValidatorId, findings, "conformance"); + return findings; } -export function validateDanglingReferences(model: AuthoredModel): ValidationReport { - const knownSpecIds = new Set(model.specs.map((entry) => entry.id)); - const findings: Finding[] = []; +/* ----- conformance/claim-separation (`05` §2 check 3; the `03` §1 edge contract) ----- */ + +const nodeTypeSet: ReadonlySet = new Set(graphNodeTypes); +const claimSet: ReadonlySet = new Set(graphClaims); +const edgeTypeSet: ReadonlySet = new Set(graphEdgeTypes); +const specKindSet: ReadonlySet = new Set(SPEC_KINDS); +const altitudeSet: ReadonlySet = new Set(SPEC_ALTITUDES); +const readinessSet: ReadonlySet = new Set(SPEC_READINESS); + +const nodeClaimByType: Readonly> = { + Primitive: "declared", + Pack: "declared", + Anchor: "anchored", + CodeNode: "anchored", +}; + +function claimSeparationFinding(message: string, subjectId: string, file?: string): Finding { + return createFinding({ + validatorId: graphValidatorIds.claimSeparation, + family: "conformance", + severity: "error", + message, + subjectId, + file, + }); +} - const appendMissingReference = (subjectId: string, targetId: SpecId, path: string): void => { - if (knownSpecIds.has(targetId)) { +/** + * The descriptor half of "node typing valid" (`05` §2 check 3): the graph is the public seam, and + * a foreign producer can carry any string in a descriptor slot the types promise is an enum. The + * floor dereferences `specKind` and `readiness`, so an unratified value fails closed here as a + * conformance error — and `evaluateReadinessFloor` evaluates no clauses over it (the same + * boundary, guarded at both ends). + */ +function checkPrimitiveDescriptors(node: PrimitiveNode, findings: Finding[]): void { + const descriptors = [ + ["specKind", node.specKind, specKindSet], + ["altitude", node.altitude, altitudeSet], + ["readiness", node.readiness, readinessSet], + ] as const; + + for (const [descriptor, value, ratified] of descriptors) { + if (ratified.has(value)) { + continue; + } + + findings.push( + createFinding({ + validatorId: graphValidatorIds.claimSeparation, + family: "conformance", + severity: "error", + message: `Spec "${node.id}" carries unknown ${descriptor} "${value}" — outside the ratified descriptor values (${[...ratified].join(" · ")}).`, + subjectId: node.id, + path: descriptor, + file: node.file, + }), + ); + } +} + +/** + * Endpoint rows evaluate only where the endpoint resolves — a dangling endpoint is referential + * integrity's finding, never a second one here. + */ +function checkEdgeContractRow(edge: GraphEdge, index: GraphIndex, findings: Finding[]): void { + const describeEdge = `Edge "${edge.from}" → (${edge.type}) → "${edge.to}"`; + const fromNode = index.nodesById.get(edge.from); + const toNode = index.nodesById.get(edge.to); + + const requireClaim = (claim: GraphClaim): boolean => { + if (edge.claim === claim) { + return true; + } + + findings.push( + claimSeparationFinding( + `${describeEdge} carries claim "${edge.claim}" — a ${edge.type} edge carries "${claim}", and the claim taxonomy is never collapsed.`, + edge.from, + fromNode?.file, + ), + ); + return false; + }; + + const requireEndpoints = (fromTypes: readonly GraphNodeType[], toType: GraphNodeType): void => { + if (fromNode !== undefined && !fromTypes.includes(fromNode.nodeType)) { + findings.push( + claimSeparationFinding( + `${describeEdge} originates from a ${fromNode.nodeType} node — the edge contract allows ${fromTypes.join(" or ")}.`, + edge.from, + fromNode.file, + ), + ); + } + + if (toNode !== undefined && toNode.nodeType !== toType) { + findings.push( + claimSeparationFinding( + `${describeEdge} targets a ${toNode.nodeType} node — the edge contract requires ${toType}.`, + edge.from, + fromNode?.file, + ), + ); + } + }; + + // The kind-typed endpoint rows (`03` §1): evaluated only where the endpoint resolves to a + // Primitive carrying a ratified kind — a non-Primitive endpoint is the endpoint row's finding, + // and an unratified kind is the descriptor check's, never a second one here. + const requireSpecKind = ( + endpoint: GraphNode | undefined, + role: "targets" | "originates from", + kinds: readonly string[], + meaning: string, + ): void => { + if (endpoint?.nodeType !== "Primitive" || !specKindSet.has(endpoint.specKind)) { + return; + } + + if (kinds.includes(endpoint.specKind)) { return; } findings.push( - createFinding( - danglingReferencesValidatorId, - "conformance", - `Authored reference from "${subjectId}" points to missing target "${targetId}" at "${path}".`, - subjectId, - targetId, - path, + claimSeparationFinding( + `${describeEdge} ${role} a ${endpoint.specKind}-kind spec — ${meaning}.`, + edge.from, + fromNode?.file, ), ); }; - for (const authoredSpec of model.specs) { - for (const [index, relation] of (authoredSpec.relations ?? []).entries()) { - appendMissingReference( - authoredSpec.id, - relation.target, - `relations[${String(index)}].target`, + switch (edge.type) { + case "satisfies": + requireClaim("anchored"); + requireEndpoints(["CodeNode"], "Primitive"); + return; + case "belongsTo": + requireClaim("declared"); + requireEndpoints(["Primitive"], "Pack"); + return; + case "verifies": + if (edge.claim === "declared") { + requireEndpoints(["Primitive"], "Primitive"); + return; + } + + if (edge.claim === "anchored") { + requireEndpoints(["Anchor"], "Primitive"); + return; + } + + findings.push( + claimSeparationFinding( + `${describeEdge} carries claim "${edge.claim}" — a verifies edge is declared (from an example) or anchored (from a test anchor), and the claim taxonomy is never collapsed.`, + edge.from, + fromNode?.file, + ), + ); + return; + case "constrainedBy": + requireClaim("declared"); + requireEndpoints(["Primitive"], "Primitive"); + requireSpecKind( + toNode, + "targets", + ["rule", "constraint"], + "constrainedBy bounds a spec by a rule- or constraint-kind spec (a typed dependency, `02` §6)", + ); + return; + case "decidedBy": + requireClaim("declared"); + requireEndpoints(["Primitive"], "Primitive"); + requireSpecKind( + toNode, + "targets", + ["decision"], + "decidedBy points at a decision-kind spec (a Decision Record)", + ); + return; + case "supersedes": + requireClaim("declared"); + requireEndpoints(["Primitive"], "Primitive"); + requireSpecKind( + fromNode, + "originates from", + ["decision"], + "supersedes is permitted only on decision specs (`02` §6)", + ); + requireSpecKind( + toNode, + "targets", + ["decision"], + "supersedes is a current forward-pointer between two decision-kind specs (Decision Records)", + ); + return; + default: + // The remaining authored relation types: refines · dependsOn — declared, + // Primitive → Primitive, any kind (the contract types no endpoint kind for them). + requireClaim("declared"); + requireEndpoints(["Primitive"], "Primitive"); + } +} + +function checkClaimSeparation(graph: GraphSchema, index: GraphIndex): readonly Finding[] { + const findings: Finding[] = []; + + for (const node of graph.nodes) { + if (!nodeTypeSet.has(node.nodeType) || !claimSet.has(node.claim)) { + findings.push( + claimSeparationFinding( + `Node "${node.id}" carries unknown typing (nodeType "${node.nodeType}", claim "${node.claim}").`, + node.id, + node.file, + ), + ); + continue; + } + + const expectedClaim = nodeClaimByType[node.nodeType]; + + if (node.claim !== expectedClaim) { + findings.push( + claimSeparationFinding( + `Node "${node.id}" (${node.nodeType}) carries claim "${node.claim}" — ${node.nodeType} nodes carry "${expectedClaim}", and the claim taxonomy is never collapsed.`, + node.id, + node.file, + ), + ); + } + + if (node.nodeType === "Primitive") { + checkPrimitiveDescriptors(node, findings); + } + } + + for (const edge of graph.edges) { + if (!edgeTypeSet.has(edge.type) || !claimSet.has(edge.claim)) { + findings.push( + claimSeparationFinding( + `Edge "${edge.from}" → (${edge.type}) → "${edge.to}" carries unknown typing (type "${edge.type}", claim "${edge.claim}").`, + edge.from, + fileOf(index, edge.from), + ), + ); + continue; + } + + checkEdgeContractRow(edge, index, findings); + } + + return findings; +} + +/* ----- conformance/verifies-linkage (`05` §2 check 4 — the surfaced half) ----- */ + +/** + * The missing-target half of the check is referential integrity's (it is reference resolution). + * What is surfaced here is the incomplete bidirectional trace — informative, never a gate: + * a declared `verifies` confers `has-verifier` only from an *enabled* example (an example-kind + * spec a resolving test anchor binds), so an unenabled or wrong-kind verifier is named loudly + * instead of silently conferring nothing. + */ +function checkVerifiesLinkage(graph: GraphSchema, index: GraphIndex): readonly Finding[] { + const anchorVerified = new Set(); + + for (const edge of graph.edges) { + // The shared resolving-test-anchor rule (delivery-facts): an anchored verifies edge whose + // source is not an Anchor node never enables — this set and the derived facts agree. + if (isResolvingTestAnchorVerify(edge, index.nodesById)) { + anchorVerified.add(edge.to); + } + } + + const findings: Finding[] = []; + + for (const edge of graph.edges) { + if (edge.type !== "verifies" || edge.claim !== "declared") { + continue; + } + + const verifier = index.primitivesById.get(edge.from); + + if (verifier === undefined) { + continue; + } + + if (verifier.specKind !== "example") { + findings.push( + createFinding({ + validatorId: graphValidatorIds.verifiesLinkage, + family: "conformance", + severity: "warning", + message: `Spec "${verifier.id}" (kind "${verifier.specKind}") declares verifies → "${edge.to}", but only an example/scenario can be an enabled verifier — the relation confers no has-verifier.`, + subjectId: verifier.id, + relatedId: edge.to, + file: verifier.file, + }), + ); + continue; + } + + if (!anchorVerified.has(verifier.id)) { + findings.push( + createFinding({ + validatorId: graphValidatorIds.verifiesLinkage, + family: "conformance", + severity: "warning", + message: `Example "${verifier.id}" declares verifies → "${edge.to}" but is not an enabled verifier — no test anchor binds it, so the spec↔test trace is incomplete and it confers no has-verifier.`, + subjectId: verifier.id, + relatedId: edge.to, + file: verifier.file, + }), ); } } - for (const authoredPack of model.packs) { - for (const [index, targetId] of authoredPack.specs.entries()) { - appendMissingReference(authoredPack.id, targetId, `specs[${String(index)}]`); + return findings; +} + +/* ----- conformance/pack-coherence (`05` §4; F4) ----- */ + +function checkPackMembers(pack: PackNode, index: GraphIndex, findings: Finding[]): void { + const memberCounts = 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); + } + } + + for (const [memberId, count] of memberCounts) { + if (count < 2) { + continue; + } + + 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).`, + subjectId: pack.id, + relatedId: memberId, + file: pack.file, + }), + ); + } +} + +function checkPackModelRefs(pack: PackNode, index: GraphIndex, findings: Finding[]): void { + for (const [position, target] of (pack.modelRefs ?? []).entries()) { + const targetNode = index.nodesById.get(target); + + // An unresolved target is referential integrity's finding. + if (targetNode === undefined) { + continue; } - for (const [index, targetId] of (authoredPack.modelRefs ?? []).entries()) { - appendMissingReference(authoredPack.id, targetId, `modelRefs[${String(index)}]`); + if (targetNode.nodeType === "Primitive" && targetNode.specKind === "model") { + continue; + } + + const targetShape = + targetNode.nodeType === "Primitive" + ? `a ${targetNode.specKind}-kind spec` + : `a ${targetNode.nodeType} node`; + + findings.push( + createFinding({ + validatorId: graphValidatorIds.packCoherence, + family: "conformance", + severity: "error", + message: `Pack "${pack.id}" modelRefs[${String(position)}] targets "${target}", which is ${targetShape} — modelRefs name the pack's model-kind vocabulary specs.`, + subjectId: pack.id, + relatedId: target, + path: `modelRefs[${String(position)}]`, + file: pack.file, + }), + ); + } +} + +function checkPackCoherence(graph: GraphSchema, index: GraphIndex): readonly Finding[] { + const findings: Finding[] = []; + + for (const node of graph.nodes) { + if (node.nodeType !== "Pack") { + continue; } + + checkPackMembers(node, index, findings); + checkPackModelRefs(node, index, findings); } - for (const anchor of model.anchors) { - if ("satisfies" in anchor) { - appendMissingReference(anchor.id, anchor.satisfies, "satisfies"); + return findings; +} + +/* ----- conformance/orphans (`05` §2 check 8 — informative) ----- */ + +function checkOrphans(graph: GraphSchema, index: GraphIndex): readonly Finding[] { + const findings: Finding[] = []; + + for (const node of graph.nodes) { + if (node.nodeType !== "Primitive") { continue; } - appendMissingReference(anchor.id, anchor.verifies, "verifies"); + const incidentEdges = + (index.edgesByFrom.get(node.id)?.length ?? 0) + (index.edgesByTo.get(node.id)?.length ?? 0); + + if (incidentEdges > 0) { + continue; + } + + findings.push( + createFinding({ + validatorId: graphValidatorIds.orphans, + family: "conformance", + severity: "warning", + message: `Spec "${node.id}" is an orphan — no relations and nothing pointing at it; it has fallen out of the graph's connective tissue (informative, never a gate).`, + subjectId: node.id, + file: node.file, + }), + ); } - return createReport(danglingReferencesValidatorId, findings, "conformance"); + return findings; } +/* ----- honesty/authoring-shape (`05` §2 check 5) ----- */ + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** - * Authoring-shape honesty (`05` §2, check 5), stood in at the authored layer until the extractor - * lands (MD-16): no spec or pack hand-authors a delivery fact. The typed sections (MD-11) reject - * this for inline literals at `tsc` time, but TypeScript's excess-property check fires only on - * fresh literals — a section assembled through an intermediate variable slips past it, and this - * runtime check is what catches it. + * The layer split: extraction hard-errors on derived graph vocabulary at the *top level* of a + * spec/pack call (`extract/reserved-property`) before derivation; this check owns *section + * interiors* — the path `tsc` never closes (a non-fresh literal, a defused corpus, a foreign + * graph producer). The scan covers carrier-level keys only (the section record and its array + * entries): nested content stays content — a vocabulary may legitimately name a term + * "implemented". */ -export function validateAuthoringShape(model: AuthoredModel): ValidationReport { - const findings: Finding[] = []; - - const appendSmuggledFact = (subjectId: string, factName: string, path: string): void => { +function checkAuthoringShape(node: PrimitiveNode, findings: Finding[]): void { + const appendSmuggledFact = (factName: string, path: string): void => { findings.push( - createFinding( - authoringShapeValidatorId, - "honesty", - `"${subjectId}" hand-authors the derived delivery fact "${factName}" at "${path}" — delivery facts are derived, never authored.`, - subjectId, - factName, + createFinding({ + validatorId: graphValidatorIds.authoringShape, + family: "honesty", + severity: "error", + message: `"${node.id}" hand-authors the derived delivery fact "${factName}" at "${path}" — delivery facts are derived, never authored.`, + subjectId: node.id, + relatedId: factName, path, - ), + file: node.file, + }), ); }; - const scanCarrier = (subjectId: string, carrier: unknown, basePath: string): void => { + const scanCarrier = (carrier: unknown, basePath: string): void => { if (!isRecord(carrier)) { return; } for (const factName of deliveryFactNames) { if (factName in carrier) { - appendSmuggledFact( - subjectId, - factName, - basePath === "" ? factName : `${basePath}.${factName}`, - ); + appendSmuggledFact(factName, `${basePath}.${factName}`); } } }; - for (const authoredSpec of model.specs) { - const specRecord = authoredSpec as unknown as Record; - scanCarrier(authoredSpec.id, specRecord, ""); + const sections = node.sections as Record | undefined; - for (const sectionName of SPEC_SECTION_NAMES) { - const section = specRecord[sectionName]; + if (sections === undefined) { + return; + } - if (Array.isArray(section)) { - for (const [index, entry] of section.entries()) { - scanCarrier(authoredSpec.id, entry, `${sectionName}[${String(index)}]`); - } - continue; - } + for (const sectionName of SPEC_SECTION_NAMES) { + const section = sections[sectionName]; - scanCarrier(authoredSpec.id, section, sectionName); + if (Array.isArray(section)) { + for (const [position, entry] of section.entries()) { + scanCarrier(entry, `${sectionName}[${String(position)}]`); + } + continue; } + + scanCarrier(section, sectionName); } +} + +/* ----- honesty/delivery-facts (`05` §2 check 6) ----- */ + +/** + * Delivery facts are derived, never authored (`02` §2) — and on the public graph seam a + * `Primitive` node's stated `deliveryFacts` must equal what the one derivation rule recomputes + * from the graph's resolving binding edges (`computeDeliveryFacts`, shared with the extractor — + * one derivation path, never two). Extractor output holds by construction; the check has teeth + * for any other graph producer: a stated fact no binding earns is authored derived truth, an + * omitted fact corrupts the backlog/drift queries, and `observed` has no producer yet + * (aspirational, the liveness rung). + */ +function checkDeliveryFacts( + graph: GraphSchema, + derivedFacts: ReadonlyMap, +): readonly Finding[] { + const findings: Finding[] = []; + const factNameSet: ReadonlySet = new Set(deliveryFactNames); - for (const authoredPack of model.packs) { - scanCarrier(authoredPack.id, authoredPack, ""); + for (const node of graph.nodes) { + if (node.nodeType !== "Primitive") { + continue; + } + + const stated = node.deliveryFacts ?? []; + const statedSet: ReadonlySet = new Set(stated); + const derivedSet: ReadonlySet = new Set(derivedFacts.get(node.id) ?? []); + + const appendFinding = (factName: string, message: string): void => { + findings.push( + createFinding({ + validatorId: graphValidatorIds.deliveryFacts, + family: "honesty", + severity: "error", + message, + subjectId: node.id, + relatedId: factName, + path: "deliveryFacts", + file: node.file, + }), + ); + }; + + for (const factName of stated) { + if (!factNameSet.has(factName)) { + appendFinding( + factName, + `Spec "${node.id}" states unknown delivery fact "${factName}" — the delivery facts are ${deliveryFactNames.join(" · ")}.`, + ); + } + } + + for (const factName of deliveryFactNames) { + if (statedSet.has(factName) && !derivedSet.has(factName)) { + const why = + factName === "observed" + ? "nothing derives it yet (aspirational, the liveness rung)" + : "no resolving binding edge derives it"; + appendFinding( + factName, + `Spec "${node.id}" states the delivery fact "${factName}" the graph does not earn — ${why}; delivery facts are derived, never authored.`, + ); + } + + if (!statedSet.has(factName) && derivedSet.has(factName)) { + appendFinding( + factName, + `Spec "${node.id}" omits the delivery fact "${factName}" its resolving bindings derive — stated facts must equal the one derivation rule's output.`, + ); + } + } } - return createReport(authoringShapeValidatorId, findings, "honesty"); + return findings; } -export function validateReadinessFloors(model: AuthoredModel): ValidationReport { +/* ----- honesty/readiness-floor (`05` §2 check 7, §3) ----- */ + +function checkReadinessFloors(graph: GraphSchema, index: GraphIndex): readonly Finding[] { const findings: Finding[] = []; - for (const authoredSpec of model.specs) { - for (const failure of evaluateReadinessFloor(authoredSpec, model)) { + for (const node of graph.nodes) { + if (node.nodeType !== "Primitive") { + continue; + } + + for (const failure of evaluateReadinessFloor(node, index)) { findings.push( - createFinding( - readinessFloorValidatorId, - "honesty", - `Spec "${authoredSpec.id}" states readiness "${authoredSpec.readiness}" but does not satisfy floor clause "${failure.clauseId}": ${failure.description}`, - authoredSpec.id, - failure.clauseId, - "readiness", - ), + createFinding({ + validatorId: graphValidatorIds.readinessFloor, + family: "honesty", + severity: "error", + message: `Spec "${node.id}" states readiness "${node.readiness}" but does not satisfy floor clause "${failure.clauseId}": ${failure.description}`, + subjectId: node.id, + relatedId: failure.clauseId, + path: "readiness", + file: node.file, + }), ); } } - return createReport(readinessFloorValidatorId, findings, "honesty"); + return findings; } +/* ----- honesty/gaps (`05` §2 check 9 — informative) ----- */ + /** - * Pre-graph authored-layer validation only. This composes the pre-graph authored-model checks and - * is not the Slice 3 graph validator gate (one validation path, MD-14). The aggregate spans both - * check families, so it carries no single `family` of its own — each finding states its family - * (`conformance` or `honesty`). + * Reads the *recomputed* facts, never the node's stated array — a stated `has-verifier` no + * binding earns must not silence the gap (that disagreement is the delivery-facts check's error; + * this check stays truthful either way). */ -export function validateAuthoredModel(model: AuthoredModel): ValidationReport { +function checkGaps( + graph: GraphSchema, + derivedFacts: ReadonlyMap, +): readonly Finding[] { + const findings: Finding[] = []; + + for (const node of graph.nodes) { + if (node.nodeType !== "Primitive" || node.readiness !== "ready") { + continue; + } + + if ((derivedFacts.get(node.id) ?? []).includes("has-verifier")) { + continue; + } + + findings.push( + createFinding({ + validatorId: graphValidatorIds.gaps, + family: "honesty", + severity: "warning", + message: `Spec "${node.id}" states readiness "ready" with no resolving verifier — a gap, informative only (ready never requires delivery facts).`, + subjectId: node.id, + file: node.file, + }), + ); + } + + return findings; +} + +/* ----- the aggregate ----- */ + +function compareCodeUnits(left: string, right: string): number { + if (left < right) { + return -1; + } + + return left > right ? 1 : 0; +} + +function sortFindings(findings: readonly Finding[]): readonly Finding[] { + return [...findings].sort( + (left, right) => + compareCodeUnits(left.file ?? "", right.file ?? "") || + (left.line ?? 0) - (right.line ?? 0) || + compareCodeUnits(left.validatorId, right.validatorId) || + compareCodeUnits(left.subjectId ?? "", right.subjectId ?? "") || + compareCodeUnits(left.relatedId ?? "", right.relatedId ?? ""), + ); +} + +/** + * The conformance + honesty checks over the one graph — the sole validation entry point + * (one validation path, MD-14): `sdp validate` is `sdp build` + this. The aggregate spans both + * check families, so it carries no single `family` of its own — each finding states its family. + */ +export function validateGraph(graph: GraphSchema): ValidationReport { + const index = buildGraphIndex(graph); + const derivedFacts = computeDeliveryFacts(graph.nodes, graph.edges); + const authoringShapeFindings: Finding[] = []; + + for (const node of graph.nodes) { + if (node.nodeType === "Primitive") { + checkAuthoringShape(node, authoringShapeFindings); + } + } + const findings = [ - ...validateDuplicateIds(model).findings, - ...validateDanglingReferences(model).findings, - ...validateAuthoringShape(model).findings, - ...validateReadinessFloors(model).findings, + ...checkReferentialIntegrity(graph, index), + ...checkDuplicateIds(graph), + ...checkClaimSeparation(graph, index), + ...checkVerifiesLinkage(graph, index), + ...checkPackCoherence(graph, index), + ...checkOrphans(graph, index), + ...authoringShapeFindings, + ...checkDeliveryFacts(graph, derivedFacts), + ...checkReadinessFloors(graph, index), + ...checkGaps(graph, derivedFacts), ]; - return createReport(authoredModelValidatorId, findings); + return { validatorId: graphReportId, findings: sortFindings(findings) }; } diff --git a/test/bootstrap.typecheck.ts b/test/bootstrap.typecheck.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/test/bootstrap.typecheck.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/test/builders.test.ts b/test/builders.test.ts index f427bd3..818805f 100644 --- a/test/builders.test.ts +++ b/test/builders.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; import { - anchorImplementation, + codeAnchor, + codeAnchorId, constrainedBy, decidedBy, dependsOn, - implAnchorId, pack, packId, ref, @@ -121,12 +121,12 @@ describe("builders", () => { it("builds identity-only anchors as plain serializable objects", () => { const implementationSource = { - id: implAnchorId("impl:orders.create-order-use-case"), + id: codeAnchorId("impl:orders.create-order-use-case"), label: "CreateOrderUseCase", satisfies: ref("spec:orders.create-order"), }; - const implementation = anchorImplementation(implementationSource); + const implementation = codeAnchor(implementationSource); const testBinding = specTest({ id: testAnchorId("test:orders.create-order.valid-cart"), label: "valid cart creates order", diff --git a/test/builders.typecheck.ts b/test/builders.typecheck.ts index 1426bc6..da7c799 100644 --- a/test/builders.typecheck.ts +++ b/test/builders.typecheck.ts @@ -1,6 +1,6 @@ import { - anchorImplementation, - implAnchorId, + codeAnchor, + codeAnchorId, pack, packId, ref, @@ -25,8 +25,8 @@ const authoredPack = pack({ specs: [ref("spec:orders.create-order")], }); -const implementationAnchor = anchorImplementation({ - id: implAnchorId("impl:orders.create-order-use-case"), +const implementationAnchor = codeAnchor({ + id: codeAnchorId("impl:orders.create-order-use-case"), label: "CreateOrderUseCase", satisfies: ref("spec:orders.create-order"), }); @@ -39,22 +39,22 @@ const testAnchor = specTest({ void [authoredSpec, authoredPack, implementationAnchor, testAnchor]; -anchorImplementation({ - id: implAnchorId("impl:orders.create-order-use-case"), +codeAnchor({ + id: codeAnchorId("impl:orders.create-order-use-case"), satisfies: ref("spec:orders.create-order"), // @ts-expect-error anchors are identity-only and do not carry readiness readiness: "ready", }); -anchorImplementation({ - id: implAnchorId("impl:orders.create-order-use-case"), +codeAnchor({ + id: codeAnchorId("impl:orders.create-order-use-case"), satisfies: ref("spec:orders.create-order"), // @ts-expect-error anchors do not accept spec sections such as intent intent: { outcome: "turn a valid cart into an order" }, }); -anchorImplementation({ - id: implAnchorId("impl:orders.create-order-use-case"), +codeAnchor({ + id: codeAnchorId("impl:orders.create-order-use-case"), satisfies: ref("spec:orders.create-order"), // @ts-expect-error anchors do not author delivery facts implemented: true, @@ -104,8 +104,8 @@ spec({ // same excess-property caveat as the envelope defenses above applies: this fires only on fresh // literals. A section assembled through an intermediate variable slips past tsc and is caught at // runtime by `honesty/authoring-shape` (MD-16; the runtime twin lives in the fixture suite); -// structurally, non-static section authoring is rejected by static reification (P5) and the -// `sdp/spec-static` lint when the extractor lands (Slice 1). +// structurally, non-static section authoring is rejected by static reification (P5), with the +// `sdp/spec-static` lint a designed-for authoring-time guard. spec({ id: specId("spec:orders.create-order"), title: "Customer creates an order", diff --git a/test/checkout-v1.test.ts b/test/checkout-v1.test.ts index 32104df..9bdcf90 100644 --- a/test/checkout-v1.test.ts +++ b/test/checkout-v1.test.ts @@ -1,43 +1,142 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + import { describe, expect, it } from "vitest"; -import { checkoutV1Model } from "../examples/checkout-v1/model.js"; -import { validateAuthoredModel } from "../src/index.js"; +import { + authoredEdgeTypes, + extract, + extractFindingIds, + graphValidatorIds, + serializeGraph, + validateGraph, +} from "../src/index.js"; + +const exampleRoot = fileURLToPath(new URL("../examples/checkout-v1", import.meta.url)); +const goldenPath = fileURLToPath( + new URL("./fixtures/checkout-v1/expected-graph.json", import.meta.url), +); + +// The extractor is the sole producer of the example's graph, and the conformance + honesty +// checks consume that graph (one validation path, MD-14). +const extraction = extract({ root: exampleRoot }); -describe("checkout-v1 tracer bullet", () => { - it("assembles a valid authored model with zero error findings", () => { - expect(validateAuthoredModel(checkoutV1Model).findings).toEqual([]); +describe("checkout-v1 tracer bullet (extractor-fed)", () => { + it("extracts the example with zero findings", () => { + expect(extraction.report.findings).toEqual([]); + expect(extraction.counts).toEqual({ specs: 9, packs: 1, anchors: 3 }); }); - it("keeps every authored relation target inside the checkout-v1 example spec set", () => { - const exampleSpecIds = new Set(checkoutV1Model.specs.map((entry) => entry.id)); + it("validates with zero errors and exactly the one surfaced absence: the unenabled invalid-cart verifier", () => { + const validation = validateGraph(extraction.graph).findings; - for (const authoredSpec of checkoutV1Model.specs) { - for (const relation of authoredSpec.relations ?? []) { - expect(exampleSpecIds.has(relation.target)).toBe(true); - } - } + expect(validation.filter((finding) => finding.severity === "error")).toEqual([]); + // The standing warning is deliberate: the invalid-cart example declares verifies without a + // test binding (the Slice-2 honesty showcase), and surfacing it is the check's job — + // informative, never a gate. + expect(validation).toHaveLength(1); + expect(validation[0]?.validatorId).toBe(graphValidatorIds.verifiesLinkage); + expect(validation[0]?.severity).toBe("warning"); + expect(validation[0]?.subjectId).toBe("spec:orders.create-order.invalid-cart"); + }); - for (const authoredPack of checkoutV1Model.packs) { - for (const specTarget of authoredPack.specs) { - expect(exampleSpecIds.has(specTarget)).toBe(true); - } + it("drops no sections: the example survives static extraction whole", () => { + const droppedSections = extraction.report.findings.filter( + (finding) => finding.validatorId === extractFindingIds.nonStaticSection, + ); + + expect(droppedSections).toEqual([]); + }); - for (const modelRef of authoredPack.modelRefs ?? []) { - expect(exampleSpecIds.has(modelRef)).toBe(true); + it("golden correctness oracle: the extractor produces the right graph, byte-for-byte", () => { + const expected = readFileSync(goldenPath, "utf8"); + + expect( + serializeGraph(extraction.graph), + "Golden graph mismatch. Review the diff against test/fixtures/checkout-v1/expected-graph.json; if the change is intended, regenerate the golden and commit the reviewed diff — the diff is the review.", + ).toBe(expected); + }); + + 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); + + for (const edge of extraction.graph.edges) { + if (relationTypes.has(edge.type) || edge.type === "belongsTo" || edge.type === "satisfies") { + expect(nodeIds.has(edge.from)).toBe(true); + expect(nodeIds.has(edge.to)).toBe(true); } } - for (const anchor of checkoutV1Model.anchors) { - const target = "satisfies" in anchor ? anchor.satisfies : anchor.verifies; - expect(exampleSpecIds.has(target)).toBe(true); + for (const node of extraction.graph.nodes) { + if (node.nodeType === "Pack") { + for (const modelRef of node.modelRefs ?? []) { + expect(nodeIds.has(modelRef)).toBe(true); + } + } } }); - it("includes the pack, implementation anchor, and spec-linked test anchor in the assembled model", () => { - expect(checkoutV1Model.packs.map((entry) => entry.id)).toEqual(["pack:checkout-v1"]); - expect(checkoutV1Model.anchors.map((entry) => entry.id)).toEqual([ + it("extracts the pack and the three anchors (the anchored layer)", () => { + expect( + extraction.graph.nodes.filter((node) => node.nodeType === "Pack").map((n) => n.id), + ).toEqual(["pack:checkout-v1"]); + expect( + extraction.graph.nodes + .filter((node) => node.nodeType === "Anchor" || node.nodeType === "CodeNode") + .map((node) => node.id) + .sort(), + ).toEqual([ + "api:orders.post", "impl:orders.create-order-use-case", "test:orders.create-order.valid-cart", ]); + + const anchoredEdges = extraction.graph.edges.filter((edge) => edge.claim === "anchored"); + expect(anchoredEdges).toEqual( + expect.arrayContaining([ + { + from: "impl:orders.create-order-use-case", + type: "satisfies", + to: "spec:orders.create-order", + claim: "anchored", + }, + { + from: "api:orders.post", + type: "satisfies", + to: "spec:orders.create-order", + claim: "anchored", + }, + { + from: "test:orders.create-order.valid-cart", + type: "verifies", + to: "spec:orders.create-order.valid-cart", + claim: "anchored", + }, + ]), + ); + expect(anchoredEdges).toHaveLength(3); + }); + + it("derives the delivery facts honestly: bound specs only, never the unenabled verifier", () => { + const factsById = new Map( + extraction.graph.nodes + .filter((node) => node.nodeType === "Primitive") + .map((node) => [node.id, node.deliveryFacts ?? []]), + ); + + // Two satisfies bindings + the enabled valid-cart example verifying it (`02` §2). + expect(factsById.get("spec:orders.create-order")).toEqual(["implemented", "has-verifier"]); + // The test anchor verifies the example directly — the example earns its own has-verifier. + expect(factsById.get("spec:orders.create-order.valid-cart")).toEqual(["has-verifier"]); + // The invalid-cart example declares verifies but has no test anchor: not an enabled + // verifier — it confers nothing and carries nothing (binding, never liveness — MD-7). + expect(factsById.get("spec:orders.create-order.invalid-cart")).toEqual([]); + + for (const [id, facts] of factsById) { + if (id !== "spec:orders.create-order" && id !== "spec:orders.create-order.valid-cart") { + expect(facts).toEqual([]); + } + } }); }); diff --git a/test/cli.test.ts b/test/cli.test.ts index 358311b..4666253 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,6 +1,27 @@ +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + import { describe, expect, it } from "vitest"; -import { SDP_HELP_TEXT, runSdpCli } from "../src/cli/sdp.js"; +import { SDP_HELP_TEXT, isCliEntrypoint, runSdpCli } from "../src/cli/sdp.js"; +import { extract } from "../src/extract/index.js"; +import { renderDesignReview } from "../src/projections/design-review.js"; +import { materializeExtractCorpus, removeMaterializedCorpus } from "./helpers/extract-corpus.js"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const exampleRoot = join(repoRoot, "examples", "checkout-v1"); function createCaptureOutput() { const stdoutChunks: string[] = []; @@ -49,26 +70,431 @@ describe("sdp cli", () => { expect(capture.readStderr()).toBe(""); }); - it("rejects build as not implemented", () => { + it("builds the checkout-v1 example: writes graph.json (and no temp leftover) and exits 0", () => { + rmSync(join(exampleRoot, "generated"), { recursive: true, force: true }); + const capture = createCaptureOutput(); + + const exitCode = runSdpCli(["build", exampleRoot], capture.output); + + expect(exitCode).toBe(0); + expect(capture.readStderr()).toBe(""); + expect(capture.readStdout()).toContain("9 specs · 1 packs · 3 anchors → 13 nodes · 25 edges"); + expect(readdirSync(join(exampleRoot, "generated"))).toEqual(["graph.json"]); + }); + + it("builds cleanly with no root argument from the repository root (the default-root path)", () => { + // The repo itself must stay a clean default root: corpora are committed defused + // (*.sdp.ts.txt / *.ts.txt), so the only *.sdp.ts under the root is the example model, and + // the anchor sweep finds only the example's anchors (recognition is by import binding — this + // repo's own tests import the protocol by relative path, so they bind nothing). + rmSync(join(repoRoot, "generated"), { recursive: true, force: true }); const capture = createCaptureOutput(); const exitCode = runSdpCli(["build"], capture.output); + expect(exitCode).toBe(0); + expect(capture.readStderr()).toBe(""); + expect(capture.readStdout()).toContain("9 specs · 1 packs · 3 anchors → 13 nodes · 25 edges"); + rmSync(join(repoRoot, "generated"), { recursive: true, force: true }); + }); + + it("passes --check-clean on the example (determinism self-check through the CLI)", () => { + const capture = createCaptureOutput(); + + const exitCode = runSdpCli(["build", exampleRoot, "--check-clean"], capture.output); + + expect(exitCode).toBe(0); + expect(capture.readStderr()).toBe(""); + }); + + it("clean-repo determinism: the full pipeline at a different absolute path is byte-identical", () => { + // --check-clean runs the pipeline twice over the *same* root, and delete-generated/-and-rerun + // reuses the same root too — neither can catch an absolute path leaking into artifact bytes. + // A working-tree copy of the authored surfaces (never `git archive`: an uncommitted example + // edit must not fail a determinism test) at a fresh absolute path pins the projection + // property: bytes are a function of the root's *content*, never its location or leftover + // local state. + const cleanRoot = mkdtempSync(join(tmpdir(), "sdp-clean-repo-")); + + try { + for (const surface of ["specs", "src", "test"]) { + cpSync(join(exampleRoot, surface), join(cleanRoot, surface), { recursive: true }); + } + + expect(runSdpCli(["view", exampleRoot, "--check-clean"], createCaptureOutput().output)).toBe( + 0, + ); + expect(runSdpCli(["view", cleanRoot, "--check-clean"], createCaptureOutput().output)).toBe(0); + + const readArtifactTree = (root: string): ReadonlyMap => { + const tree = new Map(); + + for (const entry of readdirSync(root, { recursive: true, withFileTypes: true })) { + if (entry.isFile()) { + const absolute = join(entry.parentPath, entry.name); + tree.set(absolute.slice(root.length + 1), readFileSync(absolute, "utf8")); + } + } + + return tree; + }; + + expect(readArtifactTree(join(cleanRoot, "generated"))).toEqual( + readArtifactTree(join(exampleRoot, "generated")), + ); + } finally { + rmSync(cleanRoot, { recursive: true, force: true }); + } + }); + + it("end-to-end determinism self-check: delete generated/, rebuild, byte-identical", () => { + const graphPath = join(exampleRoot, "generated", "graph.json"); + expect(runSdpCli(["build", exampleRoot], createCaptureOutput().output)).toBe(0); + const firstBuild = readFileSync(graphPath, "utf8"); + + rmSync(join(exampleRoot, "generated"), { recursive: true, force: true }); + expect(runSdpCli(["build", exampleRoot], createCaptureOutput().output)).toBe(0); + + expect(readFileSync(graphPath, "utf8")).toBe(firstBuild); + }); + + it("fails clean on a root that is not a directory: one line, exit 1, never a stack trace", () => { + const missingRoot = join(tmpdir(), "sdp-no-such-root"); + const capture = createCaptureOutput(); + + const exitCode = runSdpCli(["build", missingRoot], capture.output); + + expect(exitCode).toBe(1); + expect(capture.readStdout()).toBe(""); + expect(capture.readStderr()).toBe(`sdp build: root "${missingRoot}" is not a directory.\n`); + + // A file as root is the same invocation mistake and gets the same one-liner. + const fileRoot = join(repoRoot, "package.json"); + const fileCapture = createCaptureOutput(); + expect(runSdpCli(["validate", fileRoot], fileCapture.output)).toBe(1); + expect(fileCapture.readStderr()).toBe(`sdp validate: root "${fileRoot}" is not a directory.\n`); + }); + + it("rejects an unknown option: one line, exit 1, nothing runs", () => { + const capture = createCaptureOutput(); + + const exitCode = runSdpCli(["build", "--bogus"], capture.output); + expect(exitCode).toBe(1); expect(capture.readStdout()).toBe(""); - expect(capture.readStderr()).toBe("sdp build is not implemented yet (Slice 1: extractor).\n"); + expect(capture.readStderr()).toBe("sdp build: unknown option --bogus\n"); }); - it("rejects validate as not wired", () => { + it("rejects a second root argument: one line, exit 1, nothing runs", () => { const capture = createCaptureOutput(); - const exitCode = runSdpCli(["validate"], capture.output); + const exitCode = runSdpCli(["build", "first-root", "second-root"], capture.output); expect(exitCode).toBe(1); expect(capture.readStdout()).toBe(""); - expect(capture.readStderr()).toBe( - "sdp validate gate is not wired yet (Slice 3: graph validator gate).\n", + expect(capture.readStderr()).toBe("sdp build takes at most one root argument.\n"); + }); + + it("notes an empty authored model: zero spec files still builds and exits 0, but says where it looked", () => { + const emptyRoot = mkdtempSync(join(tmpdir(), "sdp-empty-root-")); + + try { + const capture = createCaptureOutput(); + const exitCode = runSdpCli(["build", emptyRoot], capture.output); + + // An empty authored model is conformant — no finding, the graph written, exit 0; the note + // is invocation feedback (a typo'd cwd must never be a silent success). + expect(exitCode).toBe(0); + expect(capture.readStdout()).toContain("0 specs · 0 packs · 0 anchors"); + expect(capture.readStderr()).toContain( + `note: no *.sdp.ts spec files found under ${emptyRoot}`, + ); + expect(existsSync(join(emptyRoot, "generated", "graph.json"))).toBe(true); + } finally { + rmSync(emptyRoot, { recursive: true, force: true }); + } + }); + + it("renders a finding's location exactly once, from the structured fields: file:line — [severity]", () => { + const corpusRoot = materializeExtractCorpus("invalid-non-static-id"); + + try { + const capture = createCaptureOutput(); + runSdpCli(["build", corpusRoot], capture.output); + + expect(capture.readStderr()).toMatch( + /non-static-id\.sdp\.ts:\d+ — \[error\] extract\/non-static-envelope — /, + ); + + // One diagnostic rendering rule: the location lives in the `file`/`line` fields and is + // printed by the formatter — never embedded in the message a second time. + const findingLine = capture + .readStderr() + .split("\n") + .find((line) => line.includes("extract/non-static-envelope")); + expect(findingLine?.match(/non-static-id\.sdp\.ts/g)).toHaveLength(1); + } finally { + removeMaterializedCorpus(corpusRoot); + } + }); + + it("exits 1, writes nothing, and removes a stale graph.json on a hard-error corpus", () => { + const corpusRoot = materializeExtractCorpus("invalid-non-static-id"); + + try { + const stalePath = join(corpusRoot, "generated", "graph.json"); + mkdirSync(join(corpusRoot, "generated"), { recursive: true }); + writeFileSync(stalePath, '{ "stale": true }\n', "utf8"); + + const capture = createCaptureOutput(); + const exitCode = runSdpCli(["build", corpusRoot], capture.output); + + expect(exitCode).toBe(1); + expect(capture.readStderr()).toContain("extract/non-static-envelope"); + expect(capture.readStderr()).toContain("graph.json not written"); + // The stale artifact is gone: a failed build leaves no graph that could read as current. + expect(existsSync(stalePath)).toBe(false); + } finally { + removeMaterializedCorpus(corpusRoot); + } + }); + + it("fails --check-clean on a diverging second extraction: exit 1, the stale graph.json removed", () => { + const corpusRoot = materializeExtractCorpus("anchored-binding"); + + try { + const stalePath = join(corpusRoot, "generated", "graph.json"); + mkdirSync(join(corpusRoot, "generated"), { recursive: true }); + writeFileSync(stalePath, '{ "stale": true }\n', "utf8"); + + // The divergence branch is unreachable from honest inputs (extraction is deterministic), + // so the second extraction is forced to diverge through the injection seam. + let extractions = 0; + const capture = createCaptureOutput(); + const exitCode = runSdpCli(["build", corpusRoot, "--check-clean"], capture.output, { + extract: (options) => { + extractions += 1; + const result = extract(options); + + return extractions === 1 ? result : { ...result, graph: { ...result.graph, edges: [] } }; + }, + }); + + expect(exitCode).toBe(1); + expect(capture.readStdout()).toBe(""); + expect(capture.readStderr()).toBe( + "sdp build --check-clean: two independent extractions diverged — the build is not deterministic; any previous graph.json at this root was removed.\n", + ); + // The stale artifact is gone: nothing at this root reads as current. + expect(existsSync(stalePath)).toBe(false); + } finally { + removeMaterializedCorpus(corpusRoot); + } + }); + + it("fails clean when extraction throws past discovery: one line, exit 1, the stale graph.json removed", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-unreadable-root-")); + + try { + const stalePath = join(root, "generated", "graph.json"); + mkdirSync(join(root, "generated"), { recursive: true }); + writeFileSync(stalePath, '{ "stale": true }\n', "utf8"); + + // A mid-extraction filesystem error (e.g. an unreadable file under the root) is + // deterministic only through the injection seam — never a chmod trick in a test. + const capture = createCaptureOutput(); + const exitCode = runSdpCli(["build", root], capture.output, { + extract: () => { + throw new Error("EACCES: permission denied, open 'specs/locked.sdp.ts'"); + }, + }); + + expect(exitCode).toBe(1); + expect(capture.readStdout()).toBe(""); + expect(capture.readStderr()).toBe( + "sdp build: EACCES: permission denied, open 'specs/locked.sdp.ts'\n", + ); + expect(existsSync(stalePath)).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("fails clean when generated exists as a file: one line each for build and view, never a stack trace", () => { + const corpusRoot = materializeExtractCorpus("anchored-binding"); + + try { + // `generated` as a file makes the write phase throw — and the recovery itself must not + // re-throw (a non-recursive remove raises ENOTDIR through a file parent). + writeFileSync(join(corpusRoot, "generated"), "not a directory\n", "utf8"); + + const buildCapture = createCaptureOutput(); + expect(runSdpCli(["build", corpusRoot], buildCapture.output)).toBe(1); + expect(buildCapture.readStderr()).toMatch(/^sdp build: /); + expect(buildCapture.readStderr().trimEnd().split("\n")).toHaveLength(1); + expect(buildCapture.readStderr()).not.toContain(" at "); + + const viewCapture = createCaptureOutput(); + expect(runSdpCli(["view", corpusRoot], viewCapture.output)).toBe(1); + expect(viewCapture.readStderr()).not.toContain(" at "); + } finally { + removeMaterializedCorpus(corpusRoot); + } + }); + + it("fails clean when the checks throw: one line, exit 1, the built graph.json stays", () => { + const corpusRoot = materializeExtractCorpus("anchored-binding"); + + try { + const capture = createCaptureOutput(); + const exitCode = runSdpCli(["validate", corpusRoot], capture.output, { + validateGraph: () => { + throw new Error("validator exploded"); + }, + }); + + expect(exitCode).toBe(1); + expect(capture.readStderr()).toBe("sdp validate: validator exploded\n"); + // The graph was cleanly built before the checks ran; the failure describes the checks, + // not the artifact, so graph.json stays. + expect(existsSync(join(corpusRoot, "generated", "graph.json"))).toBe(true); + } finally { + removeMaterializedCorpus(corpusRoot); + } + }); + + it("suppresses the empty-model note when spec files were found but none reified — a failed file is not an absent one", () => { + const corpusRoot = materializeExtractCorpus("invalid-wrong-builder"); + + try { + const capture = createCaptureOutput(); + const exitCode = runSdpCli(["build", corpusRoot], capture.output); + + expect(exitCode).toBe(1); + expect(capture.readStderr()).toContain("extract/invalid-id"); + expect(capture.readStderr()).not.toContain("no *.sdp.ts spec files found"); + } finally { + removeMaterializedCorpus(corpusRoot); + } + }); + + it("removes the temp twin on a failed build and a failed view — no partial .tmp artifact survives", () => { + const corpusRoot = materializeExtractCorpus("anchored-binding"); + + try { + const temporaryGraph = join(corpusRoot, "generated", "graph.json.tmp"); + const temporaryView = join(corpusRoot, "generated", "design-review.tmp"); + mkdirSync(join(corpusRoot, "generated"), { recursive: true }); + writeFileSync(temporaryGraph, "partial\n", "utf8"); + mkdirSync(temporaryView, { recursive: true }); + writeFileSync(join(temporaryView, "index.md"), "partial\n", "utf8"); + + let extractions = 0; + const buildCapture = createCaptureOutput(); + const buildExit = runSdpCli(["build", corpusRoot, "--check-clean"], buildCapture.output, { + extract: (options) => { + extractions += 1; + const result = extract(options); + + return extractions === 1 ? result : { ...result, graph: { ...result.graph, edges: [] } }; + }, + }); + + expect(buildExit).toBe(1); + expect(existsSync(temporaryGraph)).toBe(false); + + mkdirSync(temporaryView, { recursive: true }); + writeFileSync(join(temporaryView, "index.md"), "partial\n", "utf8"); + + let renders = 0; + const viewCapture = createCaptureOutput(); + const viewExit = runSdpCli(["view", corpusRoot, "--check-clean"], viewCapture.output, { + renderDesignReview: (reader) => { + renders += 1; + const pages = renderDesignReview(reader); + + return renders === 1 ? pages : pages.slice(1); + }, + }); + + expect(viewExit).toBe(1); + expect(existsSync(temporaryView)).toBe(false); + } finally { + removeMaterializedCorpus(corpusRoot); + } + }); + + it("recognizes the published-bin path: a .bin-style symlink resolves to the entry module", () => { + const directory = mkdtempSync(join(tmpdir(), "sdp-bin-")); + + try { + const entryFile = join(directory, "sdp.js"); + writeFileSync(entryFile, "// stand-in for the built CLI entry\n", "utf8"); + const binLink = join(directory, "sdp"); + symlinkSync(entryFile, binLink); + const moduleUrl = pathToFileURL(entryFile).href; + + expect(isCliEntrypoint(binLink, moduleUrl)).toBe(true); + expect(isCliEntrypoint(entryFile, moduleUrl)).toBe(true); + expect(isCliEntrypoint(join(directory, "unrelated.js"), moduleUrl)).toBe(false); + expect(isCliEntrypoint(undefined, moduleUrl)).toBe(false); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + it("validates the example: exit 0, the artifact written, and exactly the one surfaced warning", () => { + const capture = createCaptureOutput(); + + const exitCode = runSdpCli(["validate", exampleRoot, "--check-clean"], capture.output); + + expect(exitCode).toBe(0); + expect(capture.readStdout()).toContain("9 specs · 1 packs · 3 anchors → 13 nodes · 25 edges"); + expect(capture.readStdout()).toContain( + "validate: 0 errors · 1 warnings (conformance + honesty over the one graph)", ); + // The standing warning is the invalid-cart example's unenabled verifier — informative, never + // a gate (it is the surfaced absence the check exists for, not noise to silence). + expect(capture.readStderr()).toContain("conformance/verifies-linkage"); + expect(capture.readStderr()).not.toContain("[error]"); + expect(existsSync(join(exampleRoot, "generated", "graph.json"))).toBe(true); + }); + + it("gates on the checks, not the build: a clean-building broken link validates to exit 1 with the artifact kept", () => { + const corpusRoot = materializeExtractCorpus("dangling-relation"); + + try { + expect(runSdpCli(["build", corpusRoot], createCaptureOutput().output)).toBe(0); + + const capture = createCaptureOutput(); + const exitCode = runSdpCli(["validate", corpusRoot], capture.output); + + expect(exitCode).toBe(1); + expect(capture.readStderr()).toContain("conformance/referential-integrity"); + expect(capture.readStdout()).toContain("validate: 1 errors · 0 warnings"); + // The artifact stays: the graph is the faithful projection — the check errors describe the + // repo's conformance, not the artifact. + expect(existsSync(join(corpusRoot, "generated", "graph.json"))).toBe(true); + } finally { + removeMaterializedCorpus(corpusRoot); + } + }); + + it("short-circuits the checks on extraction hard errors: validate keeps build semantics", () => { + const corpusRoot = materializeExtractCorpus("invalid-non-static-id"); + + try { + const capture = createCaptureOutput(); + const exitCode = runSdpCli(["validate", corpusRoot], capture.output); + + expect(exitCode).toBe(1); + expect(capture.readStderr()).toContain("extract/non-static-envelope"); + expect(capture.readStderr()).toContain("sdp validate: hard errors present"); + expect(capture.readStdout()).not.toContain("conformance"); + expect(existsSync(join(corpusRoot, "generated", "graph.json"))).toBe(false); + } finally { + removeMaterializedCorpus(corpusRoot); + } }); it("prints help plus an unknown-command error", () => { @@ -80,4 +506,155 @@ describe("sdp cli", () => { expect(capture.readStdout()).toBe(""); expect(capture.readStderr()).toBe(`${SDP_HELP_TEXT}\n\nUnknown command: bogus\n`); }); + + it("views the example: validate + the Design Review written, with the one standing warning", () => { + const capture = createCaptureOutput(); + + const exitCode = runSdpCli(["view", exampleRoot, "--check-clean"], capture.output); + + expect(exitCode).toBe(0); + expect(capture.readStdout()).toContain( + "validate: 0 errors · 1 warnings (conformance + honesty over the one graph)", + ); + expect(capture.readStdout()).toContain("(11 pages)"); + + const viewRoot = join(exampleRoot, "generated", "design-review"); + expect(readdirSync(viewRoot).sort()).toEqual(["index.md", "pack", "spec"]); + expect(existsSync(join(viewRoot, "spec", "orders.create-order.md"))).toBe(true); + // No temp leftover: the tree lands via temp-then-rename. + expect(readdirSync(join(exampleRoot, "generated")).sort()).toEqual([ + "design-review", + "graph.json", + ]); + }); + + it("owns the view directory wholesale: a stale page does not survive a re-render", () => { + const stalePath = join(exampleRoot, "generated", "design-review", "spec", "orders.gone.md"); + mkdirSync(join(exampleRoot, "generated", "design-review", "spec"), { recursive: true }); + writeFileSync(stalePath, "# A spec deleted from the repo\n", "utf8"); + + const exitCode = runSdpCli(["view", exampleRoot], createCaptureOutput().output); + + expect(exitCode).toBe(0); + expect(existsSync(stalePath)).toBe(false); + }); + + it("regenerates the view byte-identically: delete generated/, re-view, same bytes", () => { + const pagePath = join( + exampleRoot, + "generated", + "design-review", + "spec", + "orders.create-order.md", + ); + expect(runSdpCli(["view", exampleRoot], createCaptureOutput().output)).toBe(0); + const firstRender = readFileSync(pagePath, "utf8"); + + rmSync(join(exampleRoot, "generated"), { recursive: true, force: true }); + expect(runSdpCli(["view", exampleRoot], createCaptureOutput().output)).toBe(0); + + expect(readFileSync(pagePath, "utf8")).toBe(firstRender); + }); + + it("writes the view even when checks fail: findings render in it, the exit code is validate's", () => { + const corpusRoot = materializeExtractCorpus("dangling-relation"); + + try { + const capture = createCaptureOutput(); + const exitCode = runSdpCli(["view", corpusRoot], capture.output); + + expect(exitCode).toBe(1); + expect(capture.readStderr()).toContain("conformance/referential-integrity"); + // Both artifacts stay — the graph and the view are faithful projections, and the + // review surface exists to show exactly these findings in context. + expect(existsSync(join(corpusRoot, "generated", "graph.json"))).toBe(true); + const indexPage = readFileSync( + join(corpusRoot, "generated", "design-review", "index.md"), + "utf8", + ); + expect(indexPage).toContain("conformance/referential-integrity"); + } finally { + removeMaterializedCorpus(corpusRoot); + } + }); + + it("keeps build semantics on extraction hard errors: no graph, no view, stale view removed", () => { + const corpusRoot = materializeExtractCorpus("invalid-non-static-id"); + + try { + const staleViewPath = join(corpusRoot, "generated", "design-review", "index.md"); + mkdirSync(join(corpusRoot, "generated", "design-review"), { recursive: true }); + writeFileSync(staleViewPath, "# A view from a previous run\n", "utf8"); + + const capture = createCaptureOutput(); + const exitCode = runSdpCli(["view", corpusRoot], capture.output); + + expect(exitCode).toBe(1); + expect(capture.readStderr()).toContain("extract/non-static-envelope"); + expect(existsSync(join(corpusRoot, "generated", "graph.json"))).toBe(false); + // A stale view from a previous run is as dishonest as a stale graph.json. + expect(existsSync(join(corpusRoot, "generated", "design-review"))).toBe(false); + } finally { + removeMaterializedCorpus(corpusRoot); + } + }); + + it("fails view --check-clean on a diverging second render: exit 1, the stale view removed", () => { + const corpusRoot = materializeExtractCorpus("anchored-binding"); + + try { + const viewPath = join(corpusRoot, "generated", "design-review"); + mkdirSync(viewPath, { recursive: true }); + writeFileSync(join(viewPath, "index.md"), "# A view from a previous run\n", "utf8"); + + // The divergence branch is unreachable from honest inputs (rendering is deterministic), + // so the second render is forced to diverge through the injection seam. + let renders = 0; + const capture = createCaptureOutput(); + const exitCode = runSdpCli(["view", corpusRoot, "--check-clean"], capture.output, { + renderDesignReview: (reader) => { + renders += 1; + const pages = renderDesignReview(reader); + + return renders === 1 ? pages : pages.slice(1); + }, + }); + + expect(exitCode).toBe(1); + expect(capture.readStderr()).toContain( + "sdp view --check-clean: two independent renders diverged — the view is not deterministic; any previous design-review at this root was removed.\n", + ); + // The stale view is gone; graph.json stays — the build and its determinism check were clean. + expect(existsSync(viewPath)).toBe(false); + expect(existsSync(join(corpusRoot, "generated", "graph.json"))).toBe(true); + } finally { + removeMaterializedCorpus(corpusRoot); + } + }); + + it("fails clean when the render throws: one line on stderr, exit 1, the stale view removed", () => { + const corpusRoot = materializeExtractCorpus("anchored-binding"); + + try { + const viewPath = join(corpusRoot, "generated", "design-review"); + mkdirSync(viewPath, { recursive: true }); + writeFileSync(join(viewPath, "index.md"), "# A view from a previous run\n", "utf8"); + + const capture = createCaptureOutput(); + const exitCode = runSdpCli(["view", corpusRoot], capture.output, { + renderDesignReview: () => { + throw new Error("ENOSPC: no space left on device, write"); + }, + }); + + expect(exitCode).toBe(1); + expect(capture.readStderr()).toContain("sdp view: ENOSPC: no space left on device, write\n"); + expect(existsSync(viewPath)).toBe(false); + // graph.json stays: it was written by a clean build, and the check errors (none here) and + // the render failure describe the run, not that artifact. + expect(existsSync(join(corpusRoot, "generated", "graph.json"))).toBe(true); + } finally { + removeMaterializedCorpus(corpusRoot); + } + }); }); diff --git a/test/design-review.test.ts b/test/design-review.test.ts new file mode 100644 index 0000000..eb31024 --- /dev/null +++ b/test/design-review.test.ts @@ -0,0 +1,318 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { + createReader, + extract, + pack, + packId, + refines, + renderDesignReview, + spec, + specId, + specTest, + testAnchorId, +} from "../src/index.js"; +import type { DesignReviewPage, GraphSchema } from "../src/index.js"; +import { deriveFixtureGraph } from "./helpers/fixture-graph.js"; + +const exampleRoot = fileURLToPath(new URL("../examples/checkout-v1", import.meta.url)); +const goldenRoot = fileURLToPath( + new URL("./fixtures/checkout-v1/expected-design-review", import.meta.url), +); + +const examplePages = renderDesignReview(createReader(extract({ root: exampleRoot }).graph)); + +function pageByPath(pages: readonly DesignReviewPage[], path: string): string { + const page = pages.find((entry) => entry.path === path); + + if (page === undefined) { + throw new Error(`The rendered view is missing the page "${path}".`); + } + + return page.content; +} + +function readGoldenPages(directory: string, prefix = ""): Map { + const pages = new Map(); + + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = prefix === "" ? entry.name : `${prefix}/${entry.name}`; + + if (entry.isDirectory()) { + for (const [childPath, content] of readGoldenPages(join(directory, entry.name), path)) { + pages.set(childPath, content); + } + continue; + } + + pages.set(path, readFileSync(join(directory, entry.name), "utf8")); + } + + return pages; +} + +describe("the Design Review — the one generated read-only view", () => { + it("golden correctness oracle: the renderer produces the right view, page set and bytes", () => { + const golden = readGoldenPages(goldenRoot); + + expect(examplePages.map((page) => page.path)).toEqual([...golden.keys()].sort()); + + for (const page of examplePages) { + expect( + page.content, + `Golden view mismatch at "${page.path}". Review the diff against test/fixtures/checkout-v1/expected-design-review/; if the change is intended, regenerate the golden and commit the reviewed diff — the diff is the review.`, + ).toBe(golden.get(page.path)); + } + }); + + it("re-rendering from the same graph is byte-identical (a pure projection)", () => { + const again = renderDesignReview(createReader(extract({ root: exampleRoot }).graph)); + + expect(again).toEqual(examplePages); + }); + + it("speaks binding language, never liveness: bindings present/none, observation not tracked", () => { + const createOrder = pageByPath(examplePages, "spec/orders.create-order.md"); + + expect(createOrder).toContain("- Implementation binding: **present**"); + expect(createOrder).toContain("- Verifier binding: **present**"); + expect(createOrder).toContain("- Runtime observation: **not tracked**"); + + const decision = pageByPath(examplePages, "spec/decisions.order-lifecycle.md"); + expect(decision).toContain("- Implementation binding: **none**"); + // The internal fact names never leak into the rendered prose (the view-label rule, MD-7): + // `implemented` would read as liveness; the view says "binding: present" instead. + expect(createOrder).not.toContain("`implemented`"); + }); + + it("distinguishes the enabled verifier from the unenabled one — the claim cues travel along", () => { + const createOrder = pageByPath(examplePages, "spec/orders.create-order.md"); + + expect(createOrder).toContain( + "`spec:orders.create-order.valid-cart` — Valid cart creates an order", + ); + expect(createOrder).toContain("**enabled** (a resolving test anchor binds this example)"); + expect(createOrder).toContain( + "**not enabled** (no test anchor binds this example — it confers no verifier binding)", + ); + expect(createOrder).toContain("`[anchored]`"); + expect(createOrder).toContain("`[declared]`"); + }); + + it("renders the standing warning in context — the teaching surface, on both involved pages", () => { + const invalidCart = pageByPath(examplePages, "spec/orders.create-order.invalid-cart.md"); + const createOrder = pageByPath(examplePages, "spec/orders.create-order.md"); + const index = pageByPath(examplePages, "index.md"); + + for (const content of [invalidCart, createOrder, index]) { + expect(content).toContain("conformance/verifies-linkage"); + } + }); + + it("locates findings from the structured fields — the Where column (line-free for a Primitive)", () => { + const index = pageByPath(examplePages, "index.md"); + + expect(index).toContain("| Severity | Check | Message | Where |"); + // The standing warning's subject is a spec file: `file` known, no line (Primitive nodes are + // line-free by design), and the location is never embedded in the message a second time. + expect(index).toContain("| `specs/orders/create-order-invalid-cart.sdp.ts` |"); + }); + + it("shows what a verifier covers on its own page (JS-G2: from the test back to the spec)", () => { + const validCart = pageByPath(examplePages, "spec/orders.create-order.valid-cart.md"); + + expect(validCart).toContain("verifies → [`spec:orders.create-order`](orders.create-order.md)"); + expect(validCart).toContain("the enabled verifying binding (a resolving test anchor)"); + expect(validCart).toContain("test/orders/create-order.valid-cart.test.ts:10"); + }); + + it("renders a test-anchor verifier as the enabled binding only along its contract row", () => { + // The enabled rendering, pinned over the example graph (a resolving test anchor)... + const validCart = pageByPath(examplePages, "spec/orders.create-order.valid-cart.md"); + expect(validCart).toContain("the enabled verifying binding (a resolving test anchor)"); + + // ...and the not-enabled one over a foreign graph: a declared-claim verifies edge from an + // Anchor node is off-contract (`03` §1: a test anchor's verifies edge is `anchored`) — it + // confers no verifier binding, and the enabled-binding line must not render beside it. + const graph = deriveFixtureGraph({ + specs: [ + spec({ + id: specId("spec:orders.create-order.valid-cart"), + title: "Valid cart creates an order", + kind: "example", + altitude: "story", + readiness: "idea", + intent: { outcome: "Verify the happy path." }, + }), + ], + anchors: [ + specTest({ + id: testAnchorId("test:orders.create-order.valid-cart"), + verifies: specId("spec:orders.create-order.valid-cart"), + }), + ], + }); + const foreign: GraphSchema = { + ...graph, + edges: graph.edges.map((edge) => + edge.type === "verifies" ? { ...edge, claim: "declared" } : edge, + ), + }; + + const page = pageByPath( + renderDesignReview(createReader(foreign)), + "spec/orders.create-order.valid-cart.md", + ); + + expect(page).toContain("- Verifier binding: **none**"); + expect(page).toContain( + "**not enabled** (an off-contract `verifies` edge — it confers no verifier binding)", + ); + expect(page).not.toContain("the enabled verifying binding"); + }); + + it("names the off-contract claim, never a missing anchor, when a bound example's edge is off-contract", () => { + // A resolving test anchor binds the example, but its own verifies edge rides an inferred + // claim — blaming a missing anchor would send the reader hunting for one that exists. + const graph = deriveFixtureGraph({ + specs: [ + spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "Turn a valid cart into an order." }, + }), + spec({ + id: specId("spec:orders.create-order.valid-cart"), + title: "Valid cart creates an order", + kind: "example", + altitude: "story", + readiness: "idea", + intent: { outcome: "Verify the happy path." }, + }), + ], + anchors: [ + specTest({ + id: testAnchorId("test:orders.create-order.valid-cart"), + verifies: specId("spec:orders.create-order.valid-cart"), + }), + ], + }); + const foreign: GraphSchema = { + ...graph, + edges: [ + ...graph.edges, + { + from: "spec:orders.create-order.valid-cart", + type: "verifies", + to: "spec:orders.create-order", + claim: "inferred", + }, + ], + }; + + const page = pageByPath( + renderDesignReview(createReader(foreign)), + "spec/orders.create-order.md", + ); + + expect(page).toContain( + "**not enabled** (an off-contract `verifies` edge — it confers no verifier binding)", + ); + expect(page).not.toContain("no test anchor binds this example"); + }); + + it("renders the pack as a review unit with the verifier gaps surfaced", () => { + const packPage = pageByPath(examplePages, "pack/checkout-v1.md"); + + expect(packPage).toContain("## Members"); + expect(packPage).toContain("## Verifier coverage gaps"); + expect(packPage).toContain("states no truth of its own"); + }); + + it("raises the derived-readiness banner only in the dishonest direction", () => { + // The honest divergence the example carries everywhere: stated `defined`, floor reached + // `ready` — informative header text, never a banner (the floor is not a quota). + const createOrder = pageByPath(examplePages, "spec/orders.create-order.md"); + expect(createOrder).toContain( + "**Readiness:** stated `defined` · structural floor reached: `ready`", + ); + expect(createOrder).not.toContain("Readiness divergence"); + + // The dishonest direction: stated `ready` with a blocking open question caps the floor at + // `scoped` — the banner names the first unmet clause, and the question renders loud. + const parent = spec({ + id: specId("spec:orders.order-management"), + title: "Order management", + kind: "behavior", + altitude: "epic", + readiness: "defined", + intent: { outcome: "Coordinate the slice." }, + behavior: { rules: ["The slice stays traceable."] }, + }); + const divergent = spec({ + id: specId("spec:orders.order-total-rule"), + title: "Order total matches cart math", + kind: "rule", + altitude: "story", + readiness: "ready", + intent: { + outcome: "Keep totals deterministic.", + openQuestions: [{ question: "Do bundle discounts apply per line?", blocking: true }], + }, + behavior: { rules: ["The order total is the sum of all line subtotals."] }, + relations: [refines(specId("spec:orders.order-management"))], + }); + const graph = deriveFixtureGraph({ + specs: [parent, divergent], + packs: [ + pack({ + id: packId("pack:checkout-v1"), + title: "Checkout v1", + specs: [specId("spec:orders.order-management"), specId("spec:orders.order-total-rule")], + }), + ], + }); + + const page = pageByPath( + renderDesignReview(createReader(graph)), + "spec/orders.order-total-rule.md", + ); + + expect(page).toContain("**Readiness:** stated `ready` · structural floor reached: `scoped`"); + expect(page).toContain( + "> **Readiness divergence.** This spec states `ready` but the structural floor reached is `scoped`.", + ); + expect(page).toContain("First unmet clause: `no-blocking-open-questions`"); + expect(page).toContain("- Do bundle discounts apply per line? — **blocking**"); + // The same divergence is also the floor check's error, rendered in the findings table. + expect(page).toContain("honesty/readiness-floor"); + }); + + it("names an unresolved relation target instead of linking it", () => { + const dangling = spec({ + id: specId("spec:orders.order-total-rule"), + title: "Order total matches cart math", + kind: "rule", + altitude: "story", + readiness: "idea", + intent: { outcome: "Keep totals deterministic." }, + behavior: { rules: ["The order total is the sum of all line subtotals."] }, + relations: [refines(specId("spec:orders.order-management"))], + }); + + const page = pageByPath( + renderDesignReview(createReader(deriveFixtureGraph({ specs: [dangling] }))), + "spec/orders.order-total-rule.md", + ); + + expect(page).toContain("`spec:orders.order-management` — **unresolved** (see findings)"); + expect(page).toContain("conformance/referential-integrity"); + }); +}); diff --git a/test/extract.test.ts b/test/extract.test.ts new file mode 100644 index 0000000..3e8bf30 --- /dev/null +++ b/test/extract.test.ts @@ -0,0 +1,715 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterAll, describe, expect, it } from "vitest"; + +import { + extract, + extractFindingIds, + graphValidatorIds, + serializeGraph, + validateGraph, +} from "../src/index.js"; +import type { GraphSchema, PrimitiveNode } from "../src/index.js"; +import { materializeExtractCorpus, removeMaterializedCorpus } from "./helpers/extract-corpus.js"; + +const exampleRoot = fileURLToPath(new URL("../examples/checkout-v1", import.meta.url)); + +const materializedRoots: string[] = []; + +function corpusRoot(name: string): string { + const root = materializeExtractCorpus(name); + materializedRoots.push(root); + return root; +} + +afterAll(() => { + for (const root of materializedRoots) { + removeMaterializedCorpus(root); + } +}); + +function primitiveNode(graph: GraphSchema, id: string): PrimitiveNode | undefined { + const node = graph.nodes.find((entry) => entry.id === id); + + return node?.nodeType === "Primitive" ? node : undefined; +} + +/** + * On-disk extractor corpora (the extractor reads files, not in-memory objects), should-fail / + * should-pass style: each pins one extraction finding id. The corpora are committed as + * `*.sdp.ts.txt` / `*.ts.txt` and materialized into temp directories — see + * `helpers/extract-corpus.ts`. + */ +describe("extraction corpora", () => { + it("invalid-non-static-id: envelope hard error; the static sibling still extracts (L3)", () => { + const result = extract({ root: corpusRoot("invalid-non-static-id") }); + const errors = result.report.findings.filter((finding) => finding.severity === "error"); + + expect(errors).toHaveLength(1); + expect(errors[0]?.validatorId).toBe(extractFindingIds.nonStaticEnvelope); + expect(errors[0]?.path).toBe("id"); + expect(errors[0]?.file).toBe("non-static-id.sdp.ts"); + expect(result.counts.specs).toBe(1); + expect(result.graph.nodes.map((node) => node.id)).toEqual(["spec:orders.static-sibling"]); + }); + + it("invalid-malformed-id: a static id failing the id grammar is an invalid-id hard error", () => { + const result = extract({ root: corpusRoot("invalid-malformed-id") }); + const errors = result.report.findings.filter((finding) => finding.severity === "error"); + + expect(errors).toHaveLength(1); + expect(errors[0]?.validatorId).toBe(extractFindingIds.invalidId); + expect(result.counts.specs).toBe(0); + expect(result.graph.nodes).toEqual([]); + }); + + it("invalid-non-static-section: that one property drops with a warning; the spec survives", () => { + const result = extract({ root: corpusRoot("invalid-non-static-section") }); + + expect(result.report.findings.filter((finding) => finding.severity === "error")).toEqual([]); + + const warnings = result.report.findings.filter((finding) => finding.severity === "warning"); + expect(warnings).toHaveLength(1); + expect(warnings[0]?.validatorId).toBe(extractFindingIds.nonStaticSection); + expect(warnings[0]?.path).toBe("intent.value"); + + const node = primitiveNode(result.graph, "spec:orders.non-static-section"); + expect(node?.sections?.intent?.outcome).toBe( + "Survive extraction with only the non-static property dropped.", + ); + expect(node?.sections?.intent?.value).toBeUndefined(); + }); + + it("invalid-hand-authored-satisfies-edge: a raw relations[] entry is an envelope error", () => { + const result = extract({ root: corpusRoot("invalid-hand-authored-satisfies-edge") }); + const errors = result.report.findings.filter((finding) => finding.severity === "error"); + + expect(errors).toHaveLength(1); + expect(errors[0]?.validatorId).toBe(extractFindingIds.nonStaticEnvelope); + expect(errors[0]?.path).toBe("relations[0]"); + expect(result.counts.specs).toBe(0); + expect(result.graph.nodes).toEqual([]); + expect(result.graph.edges).toEqual([]); + }); + + it("duplicate-id: both sites reported (L2); neither enters the graph; the counts record both", () => { + const result = extract({ root: corpusRoot("duplicate-id") }); + const errors = result.report.findings.filter( + (finding) => finding.validatorId === extractFindingIds.duplicateId, + ); + + expect(errors).toHaveLength(2); + expect(new Set(errors.map((finding) => finding.file)).size).toBe(2); + expect(errors.every((finding) => finding.subjectId === "spec:orders.duplicate")).toBe(true); + expect(result.graph.nodes).toEqual([]); + expect(result.counts.specs).toBe(2); + }); + + it("dangling-relation: the edge is emitted, not dropped; referential integrity flags it", () => { + const result = extract({ root: corpusRoot("dangling-relation") }); + + expect(result.report.findings).toEqual([]); + expect(result.graph.edges).toContainEqual({ + from: "spec:orders.dangling-relation", + type: "refines", + to: "spec:orders.missing-target", + claim: "declared", + }); + + const validation = validateGraph(result.graph).findings; + expect( + validation.some((finding) => finding.validatorId === graphValidatorIds.referentialIntegrity), + ).toBe(true); + }); + + it("unrecognized-statement: the stray statement warns and is ignored; the spec extracts", () => { + const result = extract({ root: corpusRoot("unrecognized-statement") }); + const warnings = result.report.findings.filter((finding) => finding.severity === "warning"); + + expect(result.report.findings.filter((finding) => finding.severity === "error")).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]?.validatorId).toBe(extractFindingIds.unrecognizedStatement); + expect(result.graph.nodes.map((node) => node.id)).toEqual(["spec:orders.recognized"]); + }); + + it("invalid-reserved-property: a hand-authored delivery fact at the top level is an envelope hard error; the sibling survives (L3)", () => { + const result = extract({ root: corpusRoot("invalid-reserved-property") }); + const errors = result.report.findings.filter((finding) => finding.severity === "error"); + + expect(errors).toHaveLength(1); + expect(errors[0]?.validatorId).toBe(extractFindingIds.reservedProperty); + expect(errors[0]?.path).toBe("deliveryFacts"); + expect(errors[0]?.subjectId).toBe("spec:orders.reserved-property"); + expect(result.counts.specs).toBe(1); + expect(result.graph.nodes.map((node) => node.id)).toEqual([ + "spec:orders.reserved-static-sibling", + ]); + }); + + it("unrecognized-property: a typoed section name drops with a warning; the spec survives without it", () => { + const result = extract({ root: corpusRoot("unrecognized-property") }); + + expect(result.report.findings.filter((finding) => finding.severity === "error")).toEqual([]); + + const warnings = result.report.findings.filter((finding) => finding.severity === "warning"); + expect(warnings).toHaveLength(1); + expect(warnings[0]?.validatorId).toBe(extractFindingIds.unrecognizedProperty); + expect(warnings[0]?.path).toBe("behaviour"); + + const node = primitiveNode(result.graph, "spec:orders.typoed-section"); + expect(node).toBeDefined(); + expect(JSON.stringify(node)).not.toContain("behaviour"); + }); + + it("id-shaped-string-content: a raw id-shaped string in section content is prose — kept, edge-free, finding-free", () => { + const result = extract({ root: corpusRoot("id-shaped-string-content") }); + + // The MD-10 guard covers the typed affordance only (`ref(…)` is rejected, below); prose that + // happens to look like an id is content by definition — the documented boundary, pinned. + expect(result.report.findings).toEqual([]); + + const node = primitiveNode(result.graph, "spec:orders.id-shaped-string"); + expect(node?.sections?.behavior?.examples).toEqual(["spec:orders.promoted-child"]); + expect(result.graph.edges).toEqual([]); + }); + + it("invalid-parse-error: a parse-broken file is excluded whole, loudly, on both surfaces; siblings survive (L3)", () => { + const result = extract({ root: corpusRoot("invalid-parse-error") }); + const errors = result.report.findings.filter((finding) => finding.severity === "error"); + + expect(errors).toHaveLength(2); + expect(errors.every((finding) => finding.validatorId === extractFindingIds.parseError)).toBe( + true, + ); + expect(errors.map((finding) => finding.file)).toEqual([ + "parse-broken-binding.ts", + "parse-broken.sdp.ts", + ]); + expect(errors.every((finding) => typeof finding.line === "number")).toBe(true); + + // No phantom carriers: nothing from either parse-broken file enters the graph — not the + // carrier the recovered AST would swallow, not the well-formed binding above the break. + expect(result.graph.nodes.map((node) => node.id)).toEqual(["spec:orders.healthy-sibling"]); + expect(result.graph.edges).toEqual([]); + expect(result.counts).toEqual({ specs: 1, packs: 0, anchors: 0 }); + expect(JSON.stringify(result.graph)).not.toContain("spec:orders.swallowed"); + }); + + it("invalid-missing-envelope-fields: every absent required field reports in one pass — spec, pack, and anchor carriers", () => { + const result = extract({ root: corpusRoot("invalid-missing-envelope-fields") }); + const errors = result.report.findings.filter((finding) => finding.severity === "error"); + + expect( + errors.every((finding) => finding.validatorId === extractFindingIds.nonStaticEnvelope), + ).toBe(true); + + const pathsByFile = new Map(); + + for (const finding of errors) { + const list = pathsByFile.get(finding.file ?? "") ?? []; + list.push(finding.path ?? ""); + pathsByFile.set(finding.file ?? "", list); + } + + expect(pathsByFile.get("bare-spec.sdp.ts")).toEqual(["id", "kind", "altitude", "readiness"]); + expect(pathsByFile.get("bare-pack.sdp.ts")).toEqual(["id", "specs"]); + expect(pathsByFile.get("bare-binding.ts")).toEqual(["id", "satisfies"]); + expect(errors).toHaveLength(8); + expect(result.graph.nodes).toEqual([]); + expect(result.counts).toEqual({ specs: 0, packs: 0, anchors: 0 }); + }); + + it("invalid-duplicate-property: a property authored twice in one carrier fails the envelope loudly; the last value never wins", () => { + const result = extract({ root: corpusRoot("invalid-duplicate-property") }); + const errors = result.report.findings.filter((finding) => finding.severity === "error"); + + expect(errors).toHaveLength(2); + expect( + errors.every((finding) => finding.validatorId === extractFindingIds.nonStaticEnvelope), + ).toBe(true); + expect(errors.every((finding) => finding.message.includes("authored more than once"))).toBe( + true, + ); + + const specError = errors.find((finding) => finding.file === "duplicate-id-property.sdp.ts"); + expect(specError?.path).toBe("id"); + expect(specError?.subjectId).toBe("spec:orders.first-authored-id"); + + const anchorError = errors.find((finding) => finding.file === "duplicate-target-property.ts"); + expect(anchorError?.path).toBe("satisfies"); + expect(anchorError?.subjectId).toBe("impl:orders.duplicate-target-binding"); + + expect(result.graph.nodes).toEqual([]); + expect(result.graph.edges).toEqual([]); + expect(JSON.stringify(result.graph)).not.toContain("spec:orders.last-authored-id"); + expect(result.counts).toEqual({ specs: 0, packs: 0, anchors: 0 }); + }); + + it("ref-in-section-content: an id builder in section content drops the owning property (MD-10)", () => { + const result = extract({ root: corpusRoot("ref-in-section-content") }); + + expect(result.report.findings.filter((finding) => finding.severity === "error")).toEqual([]); + + const warnings = result.report.findings.filter((finding) => finding.severity === "warning"); + expect(warnings).toHaveLength(1); + expect(warnings[0]?.validatorId).toBe(extractFindingIds.nonStaticSection); + expect(warnings[0]?.path).toBe("behavior.examples"); + expect(warnings[0]?.message).toContain("relations carry linkage"); + + const node = primitiveNode(result.graph, "spec:orders.ref-in-section"); + expect(node?.sections?.behavior?.rules).toEqual([ + "A real rule survives beside the dropped property.", + ]); + expect(node?.sections?.behavior?.examples).toBeUndefined(); + // Nothing was smuggled: the graph carries no edge and no content naming the ref target. + expect(result.graph.edges).toEqual([]); + expect(JSON.stringify(result.graph.nodes)).not.toContain("spec:orders.promoted-child"); + }); + + it("invalid-wrong-builder: an id wrapping the wrong builder is an invalid-id hard error — the builder's contract, restated statically", () => { + const result = extract({ root: corpusRoot("invalid-wrong-builder") }); + const errors = result.report.findings.filter((finding) => finding.severity === "error"); + + expect(errors).toHaveLength(2); + expect(errors.every((finding) => finding.validatorId === extractFindingIds.invalidId)).toBe( + true, + ); + expect( + errors.every((finding) => + finding.message.includes("the builder's own contract, restated statically"), + ), + ).toBe(true); + expect(result.counts).toEqual({ specs: 0, packs: 0, anchors: 0 }); + expect(result.graph.nodes).toEqual([]); + }); + + it("opaque-envelope-entries: a shorthand or spread entry never double-reports its fields as missing", () => { + const result = extract({ root: corpusRoot("opaque-envelope-entries") }); + const errors = result.report.findings.filter((finding) => finding.severity === "error"); + + // One fresh-literal error per carrier — and no false absence report stacked on top: a + // non-static field is not an absent one. + expect(errors).toHaveLength(2); + expect( + errors.every((finding) => finding.validatorId === extractFindingIds.nonStaticEnvelope), + ).toBe(true); + expect(result.report.findings.some((finding) => finding.message.includes("is missing"))).toBe( + false, + ); + expect(result.counts.specs).toBe(0); + }); + + it("duplicate-section-property: a repeated name inside section content drops with a warning; the first authored value survives", () => { + const result = extract({ root: corpusRoot("duplicate-section-property") }); + + expect(result.report.findings.filter((finding) => finding.severity === "error")).toEqual([]); + + const warnings = result.report.findings.filter((finding) => finding.severity === "warning"); + expect(warnings).toHaveLength(1); + expect(warnings[0]?.validatorId).toBe(extractFindingIds.nonStaticSection); + expect(warnings[0]?.path).toBe("intent.outcome"); + expect(warnings[0]?.message).toContain("authored more than once"); + + const node = primitiveNode(result.graph, "spec:orders.nested-duplicate"); + expect(node?.sections?.intent?.outcome).toBe("first authored value"); + }); +}); + +/** + * The anchored-layer corpora: anchor constants in `*.ts` source files, committed defused as + * `*.ts.txt`. Each pins one outcome, should-fail / should-pass style (`05` §5). + */ +describe("anchor extraction corpora", () => { + it("anchored-binding: the full ladder — anchored edges and delivery facts per `02` §2", () => { + const result = extract({ root: corpusRoot("anchored-binding") }); + + expect(result.report.findings).toEqual([]); + expect(result.counts.anchors).toBe(2); + + const anchoredEdges = result.graph.edges.filter((edge) => edge.claim === "anchored"); + expect(anchoredEdges).toEqual( + expect.arrayContaining([ + { + from: "impl:orders.anchored-parent-use-case", + type: "satisfies", + to: "spec:orders.anchored-parent", + claim: "anchored", + }, + { + from: "test:orders.anchored-parent.example", + type: "verifies", + to: "spec:orders.anchored-parent.example", + claim: "anchored", + }, + ]), + ); + expect(anchoredEdges).toHaveLength(2); + + const factsById = new Map( + result.graph.nodes + .filter((node) => node.nodeType === "Primitive") + .map((node) => [node.id, node.deliveryFacts ?? []]), + ); + // The parent: implemented from the resolving satisfies binding, has-verifier from the + // enabled example; the example: has-verifier from the test anchored directly to it. + expect(factsById.get("spec:orders.anchored-parent")).toEqual(["implemented", "has-verifier"]); + expect(factsById.get("spec:orders.anchored-parent.example")).toEqual(["has-verifier"]); + + const codeNode = result.graph.nodes.find( + (node) => node.id === "impl:orders.anchored-parent-use-case", + ); + const testNode = result.graph.nodes.find( + (node) => node.id === "test:orders.anchored-parent.example", + ); + expect(codeNode?.nodeType).toBe("CodeNode"); + expect(testNode?.nodeType).toBe("Anchor"); + + // The enabled trace is also conformance-clean: no verifies-linkage surfacing. + expect(validateGraph(result.graph).findings).toEqual([]); + }); + + it("unenabled-verifier: a declared verifies without a test binding confers nothing (MD-7) and is surfaced", () => { + const result = extract({ root: corpusRoot("unenabled-verifier") }); + + expect(result.report.findings).toEqual([]); + + for (const node of result.graph.nodes) { + if (node.nodeType === "Primitive") { + expect(node.deliveryFacts ?? []).toEqual([]); + } + } + + // The verifies-linkage check names the incomplete spec↔test trace — informative, never a gate. + const validation = validateGraph(result.graph).findings; + expect(validation.filter((finding) => finding.severity === "error")).toEqual([]); + expect(validation).toHaveLength(1); + expect(validation[0]?.validatorId).toBe(graphValidatorIds.verifiesLinkage); + expect(validation[0]?.subjectId).toBe("spec:orders.unverified-parent.example"); + }); + + it("invalid-non-static-anchor: envelope hard error; the static sibling still extracts (L3)", () => { + const result = extract({ root: corpusRoot("invalid-non-static-anchor") }); + const errors = result.report.findings.filter((finding) => finding.severity === "error"); + + expect(errors).toHaveLength(1); + expect(errors[0]?.validatorId).toBe(extractFindingIds.nonStaticEnvelope); + expect(errors[0]?.path).toBe("satisfies"); + expect(result.counts.anchors).toBe(1); + expect( + result.graph.nodes.some((node) => node.id === "impl:orders.static-sibling-binding"), + ).toBe(true); + }); + + it("invalid-anchor-namespace: a code anchor with a test: id is an invalid-id hard error", () => { + const result = extract({ root: corpusRoot("invalid-anchor-namespace") }); + const errors = result.report.findings.filter((finding) => finding.severity === "error"); + + expect(errors).toHaveLength(1); + expect(errors[0]?.validatorId).toBe(extractFindingIds.invalidId); + expect(result.counts.anchors).toBe(0); + }); + + it("duplicate-anchor-id: both sites reported (L2); neither enters the graph; the counts record both", () => { + const result = extract({ root: corpusRoot("duplicate-anchor-id") }); + const errors = result.report.findings.filter( + (finding) => finding.validatorId === extractFindingIds.duplicateId, + ); + + expect(errors).toHaveLength(2); + expect(new Set(errors.map((finding) => finding.file)).size).toBe(2); + expect(errors.every((finding) => finding.subjectId === "impl:orders.duplicate-binding")).toBe( + true, + ); + expect(result.graph.nodes).toEqual([]); + expect(result.counts.anchors).toBe(2); + }); + + it("dangling-anchor: the edge is emitted, no fact is conferred, and referential integrity flags it", () => { + const result = extract({ root: corpusRoot("dangling-anchor") }); + + expect(result.report.findings).toEqual([]); + expect(result.graph.edges).toContainEqual({ + from: "impl:orders.dangling-binding", + type: "satisfies", + to: "spec:orders.missing-implementation-target", + claim: "anchored", + }); + expect( + result.graph.nodes.some( + (node) => node.nodeType === "Primitive" && (node.deliveryFacts ?? []).length > 0, + ), + ).toBe(false); + + const validation = validateGraph(result.graph).findings; + expect( + validation.some((finding) => finding.validatorId === graphValidatorIds.referentialIntegrity), + ).toBe(true); + }); + + it("misplaced-anchor: authoring calls outside their surface warn and are not extracted", () => { + const result = extract({ root: corpusRoot("misplaced-anchor") }); + const warnings = result.report.findings.filter((finding) => finding.severity === "warning"); + + expect(result.report.findings.filter((finding) => finding.severity === "error")).toEqual([]); + expect(warnings).toHaveLength(2); + expect( + warnings.every((finding) => finding.validatorId === extractFindingIds.misplacedAuthoring), + ).toBe(true); + expect(result.counts.anchors).toBe(0); + expect(result.counts.specs).toBe(0); + }); + + it("non-static-anchor-label: the label drops with a warning; the binding survives whole", () => { + const result = extract({ root: corpusRoot("non-static-anchor-label") }); + const warnings = result.report.findings.filter((finding) => finding.severity === "warning"); + + expect(result.report.findings.filter((finding) => finding.severity === "error")).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]?.validatorId).toBe(extractFindingIds.nonStaticSection); + expect(warnings[0]?.path).toBe("label"); + + const node = result.graph.nodes.find((entry) => entry.id === "impl:orders.non-static-label"); + expect(node?.nodeType).toBe("CodeNode"); + expect(node?.nodeType === "CodeNode" ? node.label : "node missing").toBeUndefined(); + expect(result.graph.edges).toContainEqual({ + from: "impl:orders.non-static-label", + type: "satisfies", + to: "spec:orders.labelled-target", + claim: "anchored", + }); + }); +}); + +/** + * The import-surface and discovery corpora: the namespace import form and the discovery walk are + * extraction surface area too — a carrier the author believes exists must never silently fall + * out of the graph (L2). + */ +describe("import-surface and discovery corpora", () => { + it("namespace-import: carriers authored as `ns.builder(…)` extract on both surfaces — the full anchored ladder", () => { + const result = extract({ root: corpusRoot("namespace-import") }); + + expect(result.report.findings).toEqual([]); + expect(result.counts).toEqual({ specs: 2, packs: 0, anchors: 2 }); + + expect(result.graph.edges).toEqual( + expect.arrayContaining([ + { + from: "spec:orders.namespace-parent.example", + type: "refines", + to: "spec:orders.namespace-parent", + claim: "declared", + }, + { + from: "spec:orders.namespace-parent.example", + type: "verifies", + to: "spec:orders.namespace-parent", + claim: "declared", + }, + { + from: "impl:orders.namespace-binding", + type: "satisfies", + to: "spec:orders.namespace-parent", + claim: "anchored", + }, + { + from: "test:orders.namespace-parent.example", + type: "verifies", + to: "spec:orders.namespace-parent.example", + claim: "anchored", + }, + ]), + ); + expect(result.graph.edges).toHaveLength(4); + + const parent = primitiveNode(result.graph, "spec:orders.namespace-parent"); + expect(parent?.deliveryFacts).toEqual(["implemented", "has-verifier"]); + expect(validateGraph(result.graph).findings).toEqual([]); + }); + + it("namespace-misplaced-authoring: the misplaced-authoring sweep sees the property-access spelling (L2)", () => { + const result = extract({ root: corpusRoot("namespace-misplaced-authoring") }); + const warnings = result.report.findings.filter((finding) => finding.severity === "warning"); + + expect(result.report.findings.filter((finding) => finding.severity === "error")).toEqual([]); + expect(warnings).toHaveLength(2); + expect( + warnings.every((finding) => finding.validatorId === extractFindingIds.misplacedAuthoring), + ).toBe(true); + expect(result.counts).toEqual({ specs: 0, packs: 0, anchors: 0 }); + expect(result.graph.nodes).toEqual([]); + }); + + it("dot-directory-skipped: discovery never descends into a dot-directory, so a stray copy raises no duplicate-id noise", () => { + const root = corpusRoot("dot-directory-skipped"); + + // The stray copies are genuinely on disk — the clean result below is the walker's skip, not a + // missing fixture. + expect(existsSync(join(root, ".history", "surface-spec.sdp.ts"))).toBe(true); + expect(existsSync(join(root, ".history", "surface-binding.ts"))).toBe(true); + + const result = extract({ root }); + + expect(result.report.findings).toEqual([]); + expect(result.counts).toEqual({ specs: 1, packs: 0, anchors: 1 }); + expect(result.graph.nodes.map((node) => node.id)).toEqual([ + "spec:orders.dot-directory-surface", + "impl:orders.dot-directory-binding", + ]); + }); + + it("shadowed-namespace-local: a parameter or local shadowing the import is somebody else's value — no spurious misplaced-authoring", () => { + const result = extract({ root: corpusRoot("shadowed-namespace-local") }); + + expect( + result.report.findings.filter( + (finding) => finding.validatorId === extractFindingIds.misplacedAuthoring, + ), + ).toEqual([]); + expect(result.counts).toEqual({ specs: 0, packs: 0, anchors: 1 }); + expect(result.graph.nodes.map((node) => node.id)).toEqual(["impl:orders.shadow-surface"]); + }); + + it("default-import: a binding authored through a default-import local extracts — it never silently falls out (L2)", () => { + const result = extract({ root: corpusRoot("default-import") }); + + expect(result.report.findings).toEqual([]); + expect(result.counts).toEqual({ specs: 0, packs: 0, anchors: 1 }); + expect(result.graph.edges).toEqual([ + { + from: "impl:orders.default-import-surface", + type: "satisfies", + to: "spec:orders.default-import-parent", + claim: "anchored", + }, + ]); + }); +}); + +/** + * The graph-validator corpora: extraction is clean (the repo's authoring shape is fine), and the + * conformance + honesty checks over the derived graph carry the verdict — exactly the + * `sdp validate` = `sdp build` + checks split (one validation path, MD-14). + */ +describe("graph-validator corpora", () => { + it("invalid-ready-with-unresolved-dependency: the conformance error and the floor failure both fire — two families, two statements", () => { + const result = extract({ root: corpusRoot("invalid-ready-with-unresolved-dependency") }); + + expect(result.report.findings).toEqual([]); + + const validation = validateGraph(result.graph).findings; + const errors = validation.filter((finding) => finding.severity === "error"); + + expect(errors.map((finding) => [finding.validatorId, finding.relatedId])).toEqual([ + [graphValidatorIds.referentialIntegrity, "spec:payments.authorize-payment"], + [graphValidatorIds.readinessFloor, "all-relations-resolve"], + ]); + // The target-rung clause evaluates resolving targets only — no third failure. + expect( + validation.some( + (finding) => finding.relatedId === "depends-on-and-refines-targets-are-defined", + ), + ).toBe(false); + }); + + it("invalid-ready-with-target-below-defined: every reference resolves; the one error is the floor clause", () => { + const result = extract({ root: corpusRoot("invalid-ready-with-target-below-defined") }); + + expect(result.report.findings).toEqual([]); + + const validation = validateGraph(result.graph).findings; + const errors = validation.filter((finding) => finding.severity === "error"); + + expect(errors).toHaveLength(1); + expect(errors[0]?.validatorId).toBe(graphValidatorIds.readinessFloor); + expect(errors[0]?.subjectId).toBe("spec:orders.create-order"); + expect(errors[0]?.relatedId).toBe("depends-on-and-refines-targets-are-defined"); + }); + + it("invalid-hand-authored-delivery-fact-in-section: the smuggled key fails over the graph end-to-end (MD-16)", () => { + const result = extract({ root: corpusRoot("invalid-hand-authored-delivery-fact-in-section") }); + + // Extraction reifies section interiors as content — the honesty check is the graph's. + expect(result.report.findings).toEqual([]); + + const errors = validateGraph(result.graph).findings.filter( + (finding) => finding.severity === "error", + ); + expect(errors).toHaveLength(1); + expect(errors[0]?.validatorId).toBe(graphValidatorIds.authoringShape); + expect(errors[0]?.relatedId).toBe("has-verifier"); + expect(errors[0]?.path).toBe("behavior.has-verifier"); + }); + + it("invalid-duplicate-pack-member: a duplicated manifest entry is a pack-coherence error", () => { + const result = extract({ root: corpusRoot("invalid-duplicate-pack-member") }); + + expect(result.report.findings).toEqual([]); + + const validation = validateGraph(result.graph).findings; + expect(validation).toHaveLength(1); + expect(validation[0]?.validatorId).toBe(graphValidatorIds.packCoherence); + expect(validation[0]?.severity).toBe("error"); + expect(validation[0]?.subjectId).toBe("pack:checkout-v1"); + expect(validation[0]?.relatedId).toBe("spec:orders.create-order"); + }); + + it("invalid-non-model-modelref: a resolving but wrong-kind modelRef is a pack-coherence error", () => { + const result = extract({ root: corpusRoot("invalid-non-model-modelref") }); + + expect(result.report.findings).toEqual([]); + + const validation = validateGraph(result.graph).findings; + expect(validation).toHaveLength(1); + expect(validation[0]?.validatorId).toBe(graphValidatorIds.packCoherence); + expect(validation[0]?.severity).toBe("error"); + expect(validation[0]?.path).toBe("modelRefs[0]"); + }); + + it("non-example-verifier: a declared verifies from a non-example kind is surfaced, never gated", () => { + const result = extract({ root: corpusRoot("non-example-verifier") }); + + expect(result.report.findings).toEqual([]); + + const validation = validateGraph(result.graph).findings; + expect(validation).toHaveLength(1); + expect(validation[0]?.validatorId).toBe(graphValidatorIds.verifiesLinkage); + expect(validation[0]?.severity).toBe("warning"); + expect(validation[0]?.subjectId).toBe("spec:orders.reconciliation-workflow"); + }); + + it("orphan-spec: no relations and nothing pointing at it — a warning, never a gate", () => { + const result = extract({ root: corpusRoot("orphan-spec") }); + + expect(result.report.findings).toEqual([]); + + const validation = validateGraph(result.graph).findings; + expect(validation).toHaveLength(1); + expect(validation[0]?.validatorId).toBe(graphValidatorIds.orphans); + expect(validation[0]?.severity).toBe("warning"); + expect(validation[0]?.subjectId).toBe("spec:orders.stranded"); + }); + + it("ready-without-verifier: the cleared floor plus the surfaced gap — informative only", () => { + const result = extract({ root: corpusRoot("ready-without-verifier") }); + + expect(result.report.findings).toEqual([]); + + const validation = validateGraph(result.graph).findings; + expect(validation).toHaveLength(1); + expect(validation[0]?.validatorId).toBe(graphValidatorIds.gaps); + expect(validation[0]?.severity).toBe("warning"); + expect(validation[0]?.subjectId).toBe("spec:orders.order-total-rule"); + }); +}); + +describe("determinism self-check (rebuild twice, byte-compare — distinct from the golden oracle)", () => { + it("two independent extractions of the example serialize byte-identically", () => { + const first = serializeGraph(extract({ root: exampleRoot }).graph); + const second = serializeGraph(extract({ root: exampleRoot }).graph); + + expect(second).toBe(first); + }); +}); diff --git a/test/fixtures.test.ts b/test/fixtures.test.ts index 80f7d8b..20e3e14 100644 --- a/test/fixtures.test.ts +++ b/test/fixtures.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from "vitest"; -import { validateAuthoredModel } from "../src/index.js"; -import { activeValidatorFixtures } from "./fixtures/authored-model.fixtures.js"; +import { validateGraph } from "../src/index.js"; +import { activeValidatorFixtures } from "./fixtures/graph-validator.fixtures.js"; +import { deriveFixtureGraph } from "./helpers/fixture-graph.js"; -describe("authored-model validator fixtures (should-pass / should-fail regression net)", () => { +describe("graph-validator fixtures (should-pass / should-fail regression net)", () => { for (const fixture of activeValidatorFixtures) { it(fixture.name, () => { - const { findings } = validateAuthoredModel(fixture.model); + const { findings } = validateGraph(deriveFixtureGraph(fixture.model)); const { expect: expected } = fixture; if (expected === "pass") { @@ -17,7 +18,8 @@ describe("authored-model validator fixtures (should-pass / should-fail regressio const matching = findings.filter( (finding) => finding.validatorId === expected.validatorId && - (expected.relatedId === undefined || finding.relatedId === expected.relatedId), + (expected.relatedId === undefined || finding.relatedId === expected.relatedId) && + (expected.path === undefined || finding.path === expected.path), ); expect(matching.length).toBeGreaterThan(0); diff --git a/test/fixtures/checkout-v1/expected-design-review/index.md b/test/fixtures/checkout-v1/expected-design-review/index.md new file mode 100644 index 0000000..a279459 --- /dev/null +++ b/test/fixtures/checkout-v1/expected-design-review/index.md @@ -0,0 +1,31 @@ +# Design Review + +The one generated read-only view — a pure projection of the one graph (`graph.json`, schema `0.3.0`): 13 nodes · 25 edges. + +## Specs + +| 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.create-order`](spec/orders.create-order.md) Customer creates an order | behavior | feature | defined | ready | present | 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.valid-cart`](spec/orders.create-order.valid-cart.md) Valid cart creates an order | example | story | defined | ready | none | present | +| [`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-total-rule`](spec/orders.order-total-rule.md) Order total matches cart math | rule | story | defined | ready | none | none | + +## Packs + +- [`pack:checkout-v1`](pack/checkout-v1.md) Checkout v1 — Let customers create orders from valid carts with honest authored traceability. + +## Findings + +| Severity | Check | Message | Where | +|---|---|---|---| +| warning | `conformance/verifies-linkage` | Example "spec:orders.create-order.invalid-cart" declares verifies → "spec:orders.create-order" but is not an enabled verifier — no test anchor binds it, so the spec↔test trace is incomplete and it confers no has-verifier. | `specs/orders/create-order-invalid-cart.sdp.ts` | + +--- + +*Generated from the one graph by `sdp view` — read-only; regenerate to update.* 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 new file mode 100644 index 0000000..8d496a2 --- /dev/null +++ b/test/fixtures/checkout-v1/expected-design-review/pack/checkout-v1.md @@ -0,0 +1,41 @@ +# Checkout v1 + +`pack:checkout-v1` · Pack (the grouping / review aggregate — states no truth of its own) · authored in [specs/checkout.pack.sdp.ts](../../../specs/checkout.pack.sdp.ts) `[declared]` + +> Let customers create orders from valid carts with honest authored traceability. + +## Members + +| 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.create-order`](../spec/orders.create-order.md) Customer creates an order | behavior | feature | defined | ready | present | 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.valid-cart`](../spec/orders.create-order.valid-cart.md) Valid cart creates an order | example | story | defined | ready | none | present | +| [`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-total-rule`](../spec/orders.order-total-rule.md) Order total matches cart math | rule | story | defined | ready | none | none | + +**Vocabulary (`modelRefs`):** [`spec:orders.order-model`](../spec/orders.order-model.md) — Order-management domain vocabulary + +## Verifier coverage gaps + +Members with no verifier binding — a surfaced absence, informative, never a gate. `ready` members are the priority slice (designed, stated done, unverified): + +- [`spec:decisions.order-lifecycle`](../spec/decisions.order-lifecycle.md) — Order lifecycle keeps validation before creation (stated `defined`) +- [`spec:orders.create-order.invalid-cart`](../spec/orders.create-order.invalid-cart.md) — Invalid cart is rejected (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-total-rule`](../spec/orders.order-total-rule.md) — Order total matches cart math (stated `defined`) + +## Findings + +None — conformance + honesty clean for this page's subject. + +--- + +*Generated from the one graph by `sdp view` — read-only; regenerate to update.* diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/decisions.order-lifecycle.md b/test/fixtures/checkout-v1/expected-design-review/spec/decisions.order-lifecycle.md new file mode 100644 index 0000000..3554f42 --- /dev/null +++ b/test/fixtures/checkout-v1/expected-design-review/spec/decisions.order-lifecycle.md @@ -0,0 +1,46 @@ +# Order lifecycle keeps validation before creation + +`spec:decisions.order-lifecycle` · Decision Record (`decision`) · altitude `feature` · authored in [specs/decisions/order-lifecycle.sdp.ts](../../../specs/decisions/order-lifecycle.sdp.ts) `[declared]` + +**Readiness:** stated `defined` · structural floor reached: `ready` + +## Bindings + +- Implementation binding: **none** +- Verifier binding: **none** +- Runtime observation: **not tracked** + +## Intent + +- **outcome:** Decide when checkout-v1 may create an order. +- **value:** The authored example has one stable lifecycle rule for success and rejection paths. + +## Decision + +**Decision.** Create orders only after cart validation confirms non-empty input and sufficient inventory. + +**Rationale.** + +- The valid-cart and invalid-cart examples need one consistent gate. +- Rejecting before persistence keeps the tracer bullet small and internally consistent. + +**Consequences.** + +- Rejected carts never create partial orders. + +## Relations & impact (one hop) + +Every line is a one-hop neighbor over the curated graph: changing this spec touches this list plus the bindings above. Deeper reach is a script over the reader; symbol-level reach is the aspirational impact graph. + +- Belongs to: [`pack:checkout-v1`](../pack/checkout-v1.md) `[declared]` +- refines → [`spec:orders.create-order`](orders.create-order.md) — Customer creates an order `[declared]` +- [`spec:orders.create-order`](orders.create-order.md) — Customer creates an order — decidedBy → this spec `[declared]` +- [`spec:orders.order-management`](orders.order-management.md) — Order management — decidedBy → this spec `[declared]` + +## Findings + +None — conformance + honesty clean for this page's subject. + +--- + +*Generated from the one graph by `sdp view` — read-only; regenerate to update.* diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.invalid-cart.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.invalid-cart.md new file mode 100644 index 0000000..8a2c985 --- /dev/null +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.invalid-cart.md @@ -0,0 +1,57 @@ +# Invalid cart is rejected + +`spec:orders.create-order.invalid-cart` · Example / Scenario (`example`) · altitude `story` · authored in [specs/orders/create-order-invalid-cart.sdp.ts](../../../specs/orders/create-order-invalid-cart.sdp.ts) `[declared]` + +**Readiness:** stated `defined` · structural floor reached: `ready` + +## Bindings + +- Implementation binding: **none** +- Verifier binding: **none** +- Runtime observation: **not tracked** + +## Intent + +- **outcome:** Show that an invalid cart does not become an order. +- **value:** The authored example captures the rejection path without adding invalid fixtures to the model. + +## Behavior + +### Examples + +- Example: + - **given** + - A cart is empty or contains at least one item without available inventory. + - The cart is submitted for order creation. + - **when** + - The create-order use case validates the cart. + - **then** + - No order is created. + - The caller receives a validation error explaining why the cart is invalid. + +## Verification intent + +- **mode:** `executable` + +### Criteria + +- The use case throws when inventory is missing. +- The use case throws when the cart is empty. + +## Relations & impact (one hop) + +Every line is a one-hop neighbor over the curated graph: changing this spec touches this list plus the bindings above. Deeper reach is a script over the reader; symbol-level reach is the aspirational impact graph. + +- Belongs to: [`pack:checkout-v1`](../pack/checkout-v1.md) `[declared]` +- refines → [`spec:orders.create-order`](orders.create-order.md) — Customer creates an order `[declared]` +- verifies → [`spec:orders.create-order`](orders.create-order.md) — Customer creates an order `[declared]` + +## Findings + +| Severity | Check | Message | Where | +|---|---|---|---| +| warning | `conformance/verifies-linkage` | Example "spec:orders.create-order.invalid-cart" declares verifies → "spec:orders.create-order" but is not an enabled verifier — no test anchor binds it, so the spec↔test trace is incomplete and it confers no has-verifier. | `specs/orders/create-order-invalid-cart.sdp.ts` | + +--- + +*Generated from the one graph by `sdp view` — read-only; regenerate to update.* diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.md new file mode 100644 index 0000000..0a49bc1 --- /dev/null +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.md @@ -0,0 +1,52 @@ +# Customer creates an order + +`spec:orders.create-order` · Use Case / Behavior (`behavior`) · altitude `feature` · authored in [specs/orders/create-order.sdp.ts](../../../specs/orders/create-order.sdp.ts) `[declared]` + +**Readiness:** stated `defined` · structural floor reached: `ready` + +## Bindings + +- Implementation binding: **present** +- Verifier binding: **present** +- Runtime observation: **not tracked** + +### Implementations + +- `api:orders.post` — POST /orders ([src/orders/create-order.route.ts:6](../../../src/orders/create-order.route.ts)) `[anchored]` +- `impl:orders.create-order-use-case` — createOrderFromCart ([src/orders/create-order.use-case.ts:23](../../../src/orders/create-order.use-case.ts)) `[anchored]` + +### Verifiers + +- `spec:orders.create-order.invalid-cart` — Invalid cart is rejected ([specs/orders/create-order-invalid-cart.sdp.ts](../../../specs/orders/create-order-invalid-cart.sdp.ts)) — **not enabled** (no test anchor binds this example — it confers no verifier binding) `[declared]` +- `spec:orders.create-order.valid-cart` — Valid cart creates an order ([specs/orders/create-order-valid-cart.sdp.ts](../../../specs/orders/create-order-valid-cart.sdp.ts)) — **enabled** (a resolving test anchor binds this example) `[declared]` + +## Intent + +- **actor:** customer +- **outcome:** Turn a valid cart into an order. +- **value:** Customers can complete purchases without the example modeling the rest of checkout. + +## Relations & impact (one hop) + +Every line is a one-hop neighbor over the curated graph: changing this spec touches this list plus the bindings above. Deeper reach is a script over the reader; symbol-level reach is the aspirational impact graph. + +- Belongs to: [`pack:checkout-v1`](../pack/checkout-v1.md) `[declared]` +- constrainedBy → [`spec:orders.order-latency-constraint`](orders.order-latency-constraint.md) — Create-order latency stays within checkout budget `[declared]` +- decidedBy → [`spec:decisions.order-lifecycle`](decisions.order-lifecycle.md) — Order lifecycle keeps validation before creation `[declared]` +- refines → [`spec:orders.order-management`](orders.order-management.md) — Order management `[declared]` +- [`spec:decisions.order-lifecycle`](decisions.order-lifecycle.md) — Order lifecycle keeps validation before creation — refines → this spec `[declared]` +- [`spec:orders.create-order.invalid-cart`](orders.create-order.invalid-cart.md) — Invalid cart is rejected — refines → this spec `[declared]` +- [`spec:orders.create-order.valid-cart`](orders.create-order.valid-cart.md) — Valid cart creates an order — refines → this spec `[declared]` +- [`spec:orders.order-inventory-rule`](orders.order-inventory-rule.md) — Order creation requires available inventory — refines → this spec `[declared]` +- [`spec:orders.order-latency-constraint`](orders.order-latency-constraint.md) — Create-order latency stays within checkout budget — refines → this spec `[declared]` +- [`spec:orders.order-total-rule`](orders.order-total-rule.md) — Order total matches cart math — refines → this spec `[declared]` + +## Findings + +| Severity | Check | Message | Where | +|---|---|---|---| +| warning | `conformance/verifies-linkage` | Example "spec:orders.create-order.invalid-cart" declares verifies → "spec:orders.create-order" but is not an enabled verifier — no test anchor binds it, so the spec↔test trace is incomplete and it confers no has-verifier. | `specs/orders/create-order-invalid-cart.sdp.ts` | + +--- + +*Generated from the one graph by `sdp view` — read-only; regenerate to update.* diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.valid-cart.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.valid-cart.md new file mode 100644 index 0000000..5c16e7f --- /dev/null +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.create-order.valid-cart.md @@ -0,0 +1,61 @@ +# Valid cart creates an order + +`spec:orders.create-order.valid-cart` · Example / Scenario (`example`) · altitude `story` · authored in [specs/orders/create-order-valid-cart.sdp.ts](../../../specs/orders/create-order-valid-cart.sdp.ts) `[declared]` + +**Readiness:** stated `defined` · structural floor reached: `ready` + +## Bindings + +- Implementation binding: **none** +- Verifier binding: **present** +- Runtime observation: **not tracked** + +### Verifiers + +- `test:orders.create-order.valid-cart` — valid cart verifies the create-order happy path ([test/orders/create-order.valid-cart.test.ts:10](../../../test/orders/create-order.valid-cart.test.ts)) — the enabled verifying binding (a resolving test anchor) `[anchored]` + +## Intent + +- **outcome:** Show that a valid cart can become an order. +- **value:** The authored example demonstrates the happy path for create-order. + +## Behavior + +### Examples + +- Example: + - **given** + - A customer has a cart with one or more line items. + - Every cart item is in stock. + - Each line item has a positive quantity and a unit price. + - **when** + - The customer submits the cart for order creation. + - **then** + - An order is created. + - The order total equals the sum of quantity multiplied by unit price for each line item. + - The order contains the original cart lines. + +## Verification intent + +- **mode:** `executable` + +### Criteria + +- The order result contains a stable id. +- The returned total matches the cart math. + +## Relations & impact (one hop) + +Every line is a one-hop neighbor over the curated graph: changing this spec touches this list plus the bindings above. Deeper reach is a script over the reader; symbol-level reach is the aspirational impact graph. + +- Belongs to: [`pack:checkout-v1`](../pack/checkout-v1.md) `[declared]` +- refines → [`spec:orders.create-order`](orders.create-order.md) — Customer creates an order `[declared]` +- verifies → [`spec:orders.create-order`](orders.create-order.md) — Customer creates an order `[declared]` + +## Findings + +None — conformance + honesty clean for this page's subject. + +--- + +*Generated from the one graph by `sdp view` — read-only; regenerate to update.* diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-inventory-rule.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-inventory-rule.md new file mode 100644 index 0000000..c0603c6 --- /dev/null +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-inventory-rule.md @@ -0,0 +1,38 @@ +# Order creation requires available inventory + +`spec:orders.order-inventory-rule` · Business Rule (`rule`) · altitude `story` · authored in [specs/orders/order-inventory-rule.sdp.ts](../../../specs/orders/order-inventory-rule.sdp.ts) `[declared]` + +**Readiness:** stated `defined` · structural floor reached: `ready` + +## Bindings + +- Implementation binding: **none** +- Verifier binding: **none** +- Runtime observation: **not tracked** + +## Intent + +- **outcome:** Reject carts whose items are not fully available. +- **value:** Order creation does not over-promise unavailable stock. + +## Behavior + +### Rules + +- Every cart line must have at least the requested quantity available. +- Any unavailable line blocks order creation for the whole cart. + +## Relations & impact (one hop) + +Every line is a one-hop neighbor over the curated graph: changing this spec touches this list plus the bindings above. Deeper reach is a script over the reader; symbol-level reach is the aspirational impact graph. + +- Belongs to: [`pack:checkout-v1`](../pack/checkout-v1.md) `[declared]` +- refines → [`spec:orders.create-order`](orders.create-order.md) — Customer creates an order `[declared]` + +## Findings + +None — conformance + honesty clean for this page's subject. + +--- + +*Generated from the one graph by `sdp view` — read-only; regenerate to update.* diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-latency-constraint.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-latency-constraint.md new file mode 100644 index 0000000..3901d95 --- /dev/null +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-latency-constraint.md @@ -0,0 +1,38 @@ +# Create-order latency stays within checkout budget + +`spec:orders.order-latency-constraint` · Constraint (NFR) (`constraint`) · altitude `story` · authored in [specs/orders/order-latency-constraint.sdp.ts](../../../specs/orders/order-latency-constraint.sdp.ts) `[declared]` + +**Readiness:** stated `defined` · structural floor reached: `ready` + +## Bindings + +- Implementation binding: **none** +- Verifier binding: **none** +- Runtime observation: **not tracked** + +## Intent + +- **outcome:** Keep create-order fast enough for interactive checkout. +- **value:** Customers are not left waiting after submitting a valid cart. + +## Constraints + +| Flavor | Statement | Target | Measurable by | +|---|---|---|---| +| performance | Create-order should respond within the checkout latency budget. | latency.p95.lt:250ms | — | + +## Relations & impact (one hop) + +Every line is a one-hop neighbor over the curated graph: changing this spec touches this list plus the bindings above. Deeper reach is a script over the reader; symbol-level reach is the aspirational impact graph. + +- Belongs to: [`pack:checkout-v1`](../pack/checkout-v1.md) `[declared]` +- refines → [`spec:orders.create-order`](orders.create-order.md) — Customer creates an order `[declared]` +- [`spec:orders.create-order`](orders.create-order.md) — Customer creates an order — constrainedBy → this spec `[declared]` + +## Findings + +None — conformance + honesty clean for this page's subject. + +--- + +*Generated from the one graph by `sdp view` — read-only; regenerate to update.* diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-management.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-management.md new file mode 100644 index 0000000..724446e --- /dev/null +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-management.md @@ -0,0 +1,40 @@ +# Order management + +`spec:orders.order-management` · Use Case / Behavior (`behavior`) · altitude `epic` · authored in [specs/orders/order-management.sdp.ts](../../../specs/orders/order-management.sdp.ts) `[declared]` + +**Readiness:** stated `defined` · structural floor reached: `ready` + +## Bindings + +- Implementation binding: **none** +- Verifier binding: **none** +- Runtime observation: **not tracked** + +## Intent + +- **outcome:** Coordinate the authored order-management slice for checkout v1. +- **value:** The pack can express order creation behavior without modeling the full checkout flow. + +## Behavior + +### Rules + +- Order management keeps order creation, rules, constraints, and decisions traceable in one authored slice. +- Every order-management child spec keeps its targets inside the checkout-v1 example set. + +## Relations & impact (one hop) + +Every line is a one-hop neighbor over the curated graph: changing this spec touches this list plus the bindings above. Deeper reach is a script over the reader; symbol-level reach is the aspirational impact graph. + +- Belongs to: [`pack:checkout-v1`](../pack/checkout-v1.md) `[declared]` +- decidedBy → [`spec:decisions.order-lifecycle`](decisions.order-lifecycle.md) — Order lifecycle keeps validation before creation `[declared]` +- [`spec:orders.create-order`](orders.create-order.md) — Customer creates an order — refines → this spec `[declared]` +- [`spec:orders.order-model`](orders.order-model.md) — Order-management domain vocabulary — refines → this spec `[declared]` + +## Findings + +None — conformance + honesty clean for this page's subject. + +--- + +*Generated from the one graph by `sdp view` — read-only; regenerate to update.* diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-model.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-model.md new file mode 100644 index 0000000..6cd363c --- /dev/null +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-model.md @@ -0,0 +1,41 @@ +# Order-management domain vocabulary + +`spec:orders.order-model` · Domain Model (`model`) · altitude `story` · authored in [specs/orders/order-model.sdp.ts](../../../specs/orders/order-model.sdp.ts) `[declared]` + +**Readiness:** stated `defined` · structural floor reached: `ready` + +## Bindings + +- Implementation binding: **none** +- Verifier binding: **none** +- Runtime observation: **not tracked** + +## Intent + +- **outcome:** Define the core terms used by the checkout-v1 order-management slice. +- **value:** Specs, code, and tests use the same vocabulary for carts, orders, and inventory. + +## Domain vocabulary + +| Term | Definition | +|---|---| +| cart | A customer-selected set of line items that has not yet become an order. | +| cartLine | A requested product, quantity, and unit price inside a cart. | +| inventorySnapshot | The available quantity for each product at validation time. | +| order | The persisted result of accepting a valid cart. | +| orderTotal | The sum of all accepted cart line subtotals. | + +## Relations & impact (one hop) + +Every line is a one-hop neighbor over the curated graph: changing this spec touches this list plus the bindings above. Deeper reach is a script over the reader; symbol-level reach is the aspirational impact graph. + +- Belongs to: [`pack:checkout-v1`](../pack/checkout-v1.md) `[declared]` +- refines → [`spec:orders.order-management`](orders.order-management.md) — Order management `[declared]` + +## Findings + +None — conformance + honesty clean for this page's subject. + +--- + +*Generated from the one graph by `sdp view` — read-only; regenerate to update.* diff --git a/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-total-rule.md b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-total-rule.md new file mode 100644 index 0000000..ac68aa1 --- /dev/null +++ b/test/fixtures/checkout-v1/expected-design-review/spec/orders.order-total-rule.md @@ -0,0 +1,38 @@ +# Order total matches cart math + +`spec:orders.order-total-rule` · Business Rule (`rule`) · altitude `story` · authored in [specs/orders/order-total-rule.sdp.ts](../../../specs/orders/order-total-rule.sdp.ts) `[declared]` + +**Readiness:** stated `defined` · structural floor reached: `ready` + +## Bindings + +- Implementation binding: **none** +- Verifier binding: **none** +- Runtime observation: **not tracked** + +## Intent + +- **outcome:** Keep the order total equal to the sum of cart line subtotals. +- **value:** Customers and downstream systems see one deterministic order total. + +## Behavior + +### Rules + +- Each line subtotal is quantity multiplied by unit price. +- The order total is the sum of all line subtotals. + +## Relations & impact (one hop) + +Every line is a one-hop neighbor over the curated graph: changing this spec touches this list plus the bindings above. Deeper reach is a script over the reader; symbol-level reach is the aspirational impact graph. + +- Belongs to: [`pack:checkout-v1`](../pack/checkout-v1.md) `[declared]` +- refines → [`spec:orders.create-order`](orders.create-order.md) — Customer creates an order `[declared]` + +## Findings + +None — conformance + honesty clean for this page's subject. + +--- + +*Generated from the one graph by `sdp view` — read-only; regenerate to update.* diff --git a/test/fixtures/checkout-v1/expected-graph.json b/test/fixtures/checkout-v1/expected-graph.json new file mode 100644 index 0000000..fe00e35 --- /dev/null +++ b/test/fixtures/checkout-v1/expected-graph.json @@ -0,0 +1,438 @@ +{ + "schemaVersion": "0.3.0", + "nodes": [ + { + "id": "api:orders.post", + "nodeType": "CodeNode", + "claim": "anchored", + "label": "POST /orders", + "file": "src/orders/create-order.route.ts", + "line": 6 + }, + { + "id": "impl:orders.create-order-use-case", + "nodeType": "CodeNode", + "claim": "anchored", + "label": "createOrderFromCart", + "file": "src/orders/create-order.use-case.ts", + "line": 23 + }, + { + "id": "pack:checkout-v1", + "nodeType": "Pack", + "claim": "declared", + "title": "Checkout v1", + "framing": "Let customers create orders from valid carts with honest authored traceability.", + "file": "specs/checkout.pack.sdp.ts", + "modelRefs": [ + "spec:orders.order-model" + ] + }, + { + "id": "spec:decisions.order-lifecycle", + "nodeType": "Primitive", + "claim": "declared", + "specKind": "decision", + "altitude": "feature", + "readiness": "defined", + "title": "Order lifecycle keeps validation before creation", + "file": "specs/decisions/order-lifecycle.sdp.ts", + "sections": { + "intent": { + "outcome": "Decide when checkout-v1 may create an order.", + "value": "The authored example has one stable lifecycle rule for success and rejection paths." + }, + "decision": { + "decision": "Create orders only after cart validation confirms non-empty input and sufficient inventory.", + "rationale": [ + "The valid-cart and invalid-cart examples need one consistent gate.", + "Rejecting before persistence keeps the tracer bullet small and internally consistent." + ], + "consequences": [ + "Rejected carts never create partial orders." + ] + } + } + }, + { + "id": "spec:orders.create-order", + "nodeType": "Primitive", + "claim": "declared", + "specKind": "behavior", + "altitude": "feature", + "readiness": "defined", + "title": "Customer creates an order", + "file": "specs/orders/create-order.sdp.ts", + "sections": { + "intent": { + "actor": "customer", + "outcome": "Turn a valid cart into an order.", + "value": "Customers can complete purchases without the example modeling the rest of checkout." + } + }, + "deliveryFacts": [ + "implemented", + "has-verifier" + ] + }, + { + "id": "spec:orders.create-order.invalid-cart", + "nodeType": "Primitive", + "claim": "declared", + "specKind": "example", + "altitude": "story", + "readiness": "defined", + "title": "Invalid cart is rejected", + "file": "specs/orders/create-order-invalid-cart.sdp.ts", + "sections": { + "intent": { + "outcome": "Show that an invalid cart does not become an order.", + "value": "The authored example captures the rejection path without adding invalid fixtures to the model." + }, + "behavior": { + "examples": [ + { + "given": [ + "A cart is empty or contains at least one item without available inventory.", + "The cart is submitted for order creation." + ], + "when": [ + "The create-order use case validates the cart." + ], + "then": [ + "No order is created.", + "The caller receives a validation error explaining why the cart is invalid." + ] + } + ] + }, + "verification": { + "mode": "executable", + "criteria": [ + "The use case throws when inventory is missing.", + "The use case throws when the cart is empty." + ] + } + } + }, + { + "id": "spec:orders.create-order.valid-cart", + "nodeType": "Primitive", + "claim": "declared", + "specKind": "example", + "altitude": "story", + "readiness": "defined", + "title": "Valid cart creates an order", + "file": "specs/orders/create-order-valid-cart.sdp.ts", + "sections": { + "intent": { + "outcome": "Show that a valid cart can become an order.", + "value": "The authored example demonstrates the happy path for create-order." + }, + "behavior": { + "examples": [ + { + "given": [ + "A customer has a cart with one or more line items.", + "Every cart item is in stock.", + "Each line item has a positive quantity and a unit price." + ], + "when": [ + "The customer submits the cart for order creation." + ], + "then": [ + "An order is created.", + "The order total equals the sum of quantity multiplied by unit price for each line item.", + "The order contains the original cart lines." + ] + } + ] + }, + "verification": { + "mode": "executable", + "criteria": [ + "The order result contains a stable id.", + "The returned total matches the cart math." + ] + } + }, + "deliveryFacts": [ + "has-verifier" + ] + }, + { + "id": "spec:orders.order-inventory-rule", + "nodeType": "Primitive", + "claim": "declared", + "specKind": "rule", + "altitude": "story", + "readiness": "defined", + "title": "Order creation requires available inventory", + "file": "specs/orders/order-inventory-rule.sdp.ts", + "sections": { + "intent": { + "outcome": "Reject carts whose items are not fully available.", + "value": "Order creation does not over-promise unavailable stock." + }, + "behavior": { + "rules": [ + "Every cart line must have at least the requested quantity available.", + "Any unavailable line blocks order creation for the whole cart." + ] + } + } + }, + { + "id": "spec:orders.order-latency-constraint", + "nodeType": "Primitive", + "claim": "declared", + "specKind": "constraint", + "altitude": "story", + "readiness": "defined", + "title": "Create-order latency stays within checkout budget", + "file": "specs/orders/order-latency-constraint.sdp.ts", + "sections": { + "intent": { + "outcome": "Keep create-order fast enough for interactive checkout.", + "value": "Customers are not left waiting after submitting a valid cart." + }, + "constraints": [ + { + "flavor": "performance", + "statement": "Create-order should respond within the checkout latency budget.", + "target": "latency.p95.lt:250ms" + } + ] + } + }, + { + "id": "spec:orders.order-management", + "nodeType": "Primitive", + "claim": "declared", + "specKind": "behavior", + "altitude": "epic", + "readiness": "defined", + "title": "Order management", + "file": "specs/orders/order-management.sdp.ts", + "sections": { + "intent": { + "outcome": "Coordinate the authored order-management slice for checkout v1.", + "value": "The pack can express order creation behavior without modeling the full checkout flow." + }, + "behavior": { + "rules": [ + "Order management keeps order creation, rules, constraints, and decisions traceable in one authored slice.", + "Every order-management child spec keeps its targets inside the checkout-v1 example set." + ] + } + } + }, + { + "id": "spec:orders.order-model", + "nodeType": "Primitive", + "claim": "declared", + "specKind": "model", + "altitude": "story", + "readiness": "defined", + "title": "Order-management domain vocabulary", + "file": "specs/orders/order-model.sdp.ts", + "sections": { + "intent": { + "outcome": "Define the core terms used by the checkout-v1 order-management slice.", + "value": "Specs, code, and tests use the same vocabulary for carts, orders, and inventory." + }, + "model": { + "terms": { + "cart": "A customer-selected set of line items that has not yet become an order.", + "cartLine": "A requested product, quantity, and unit price inside a cart.", + "inventorySnapshot": "The available quantity for each product at validation time.", + "order": "The persisted result of accepting a valid cart.", + "orderTotal": "The sum of all accepted cart line subtotals." + } + } + } + }, + { + "id": "spec:orders.order-total-rule", + "nodeType": "Primitive", + "claim": "declared", + "specKind": "rule", + "altitude": "story", + "readiness": "defined", + "title": "Order total matches cart math", + "file": "specs/orders/order-total-rule.sdp.ts", + "sections": { + "intent": { + "outcome": "Keep the order total equal to the sum of cart line subtotals.", + "value": "Customers and downstream systems see one deterministic order total." + }, + "behavior": { + "rules": [ + "Each line subtotal is quantity multiplied by unit price.", + "The order total is the sum of all line subtotals." + ] + } + } + }, + { + "id": "test:orders.create-order.valid-cart", + "nodeType": "Anchor", + "claim": "anchored", + "label": "valid cart verifies the create-order happy path", + "file": "test/orders/create-order.valid-cart.test.ts", + "line": 10 + } + ], + "edges": [ + { + "from": "api:orders.post", + "type": "satisfies", + "to": "spec:orders.create-order", + "claim": "anchored" + }, + { + "from": "impl:orders.create-order-use-case", + "type": "satisfies", + "to": "spec:orders.create-order", + "claim": "anchored" + }, + { + "from": "spec:decisions.order-lifecycle", + "type": "belongsTo", + "to": "pack:checkout-v1", + "claim": "declared" + }, + { + "from": "spec:decisions.order-lifecycle", + "type": "refines", + "to": "spec:orders.create-order", + "claim": "declared" + }, + { + "from": "spec:orders.create-order", + "type": "belongsTo", + "to": "pack:checkout-v1", + "claim": "declared" + }, + { + "from": "spec:orders.create-order", + "type": "constrainedBy", + "to": "spec:orders.order-latency-constraint", + "claim": "declared" + }, + { + "from": "spec:orders.create-order", + "type": "decidedBy", + "to": "spec:decisions.order-lifecycle", + "claim": "declared" + }, + { + "from": "spec:orders.create-order", + "type": "refines", + "to": "spec:orders.order-management", + "claim": "declared" + }, + { + "from": "spec:orders.create-order.invalid-cart", + "type": "belongsTo", + "to": "pack:checkout-v1", + "claim": "declared" + }, + { + "from": "spec:orders.create-order.invalid-cart", + "type": "refines", + "to": "spec:orders.create-order", + "claim": "declared" + }, + { + "from": "spec:orders.create-order.invalid-cart", + "type": "verifies", + "to": "spec:orders.create-order", + "claim": "declared" + }, + { + "from": "spec:orders.create-order.valid-cart", + "type": "belongsTo", + "to": "pack:checkout-v1", + "claim": "declared" + }, + { + "from": "spec:orders.create-order.valid-cart", + "type": "refines", + "to": "spec:orders.create-order", + "claim": "declared" + }, + { + "from": "spec:orders.create-order.valid-cart", + "type": "verifies", + "to": "spec:orders.create-order", + "claim": "declared" + }, + { + "from": "spec:orders.order-inventory-rule", + "type": "belongsTo", + "to": "pack:checkout-v1", + "claim": "declared" + }, + { + "from": "spec:orders.order-inventory-rule", + "type": "refines", + "to": "spec:orders.create-order", + "claim": "declared" + }, + { + "from": "spec:orders.order-latency-constraint", + "type": "belongsTo", + "to": "pack:checkout-v1", + "claim": "declared" + }, + { + "from": "spec:orders.order-latency-constraint", + "type": "refines", + "to": "spec:orders.create-order", + "claim": "declared" + }, + { + "from": "spec:orders.order-management", + "type": "belongsTo", + "to": "pack:checkout-v1", + "claim": "declared" + }, + { + "from": "spec:orders.order-management", + "type": "decidedBy", + "to": "spec:decisions.order-lifecycle", + "claim": "declared" + }, + { + "from": "spec:orders.order-model", + "type": "belongsTo", + "to": "pack:checkout-v1", + "claim": "declared" + }, + { + "from": "spec:orders.order-model", + "type": "refines", + "to": "spec:orders.order-management", + "claim": "declared" + }, + { + "from": "spec:orders.order-total-rule", + "type": "belongsTo", + "to": "pack:checkout-v1", + "claim": "declared" + }, + { + "from": "spec:orders.order-total-rule", + "type": "refines", + "to": "spec:orders.create-order", + "claim": "declared" + }, + { + "from": "test:orders.create-order.valid-cart", + "type": "verifies", + "to": "spec:orders.create-order.valid-cart", + "claim": "anchored" + } + ] +} diff --git a/test/fixtures/extract/anchored-binding/bindings.ts.txt b/test/fixtures/extract/anchored-binding/bindings.ts.txt new file mode 100644 index 0000000..fb2f326 --- /dev/null +++ b/test/fixtures/extract/anchored-binding/bindings.ts.txt @@ -0,0 +1,12 @@ +import { codeAnchor, codeAnchorId, ref, specTest, testAnchorId } from "@libar-dev/software-delivery-protocol"; + +export const anchoredParentUseCaseAnchor = codeAnchor({ + id: codeAnchorId("impl:orders.anchored-parent-use-case"), + label: "anchoredParentUseCase", + satisfies: ref("spec:orders.anchored-parent"), +}); + +export const anchoredParentExampleTest = specTest({ + id: testAnchorId("test:orders.anchored-parent.example"), + verifies: ref("spec:orders.anchored-parent.example"), +}); diff --git a/test/fixtures/extract/anchored-binding/parent.sdp.ts.txt b/test/fixtures/extract/anchored-binding/parent.sdp.ts.txt new file mode 100644 index 0000000..1a334c7 --- /dev/null +++ b/test/fixtures/extract/anchored-binding/parent.sdp.ts.txt @@ -0,0 +1,10 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const anchoredParentSpec = spec({ + id: specId("spec:orders.anchored-parent"), + title: "Parent spec with a full anchored ladder", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "Earn implemented and has-verifier from resolving bindings." }, +}); diff --git a/test/fixtures/extract/anchored-binding/verifying-example.sdp.ts.txt b/test/fixtures/extract/anchored-binding/verifying-example.sdp.ts.txt new file mode 100644 index 0000000..e37778d --- /dev/null +++ b/test/fixtures/extract/anchored-binding/verifying-example.sdp.ts.txt @@ -0,0 +1,16 @@ +import { refines, spec, specId, verifies } from "@libar-dev/software-delivery-protocol"; + +// The enabled verifier: this example declares verifies(parent), and the test anchor in +// bindings.ts verifies *this example* — two edges, two targets, no transitive shortcut (`02` §2). +export const anchoredParentExampleSpec = spec({ + id: specId("spec:orders.anchored-parent.example"), + title: "Example verifying the anchored parent", + kind: "example", + altitude: "story", + readiness: "idea", + intent: { outcome: "Confer has-verifier on the parent as an enabled verifier." }, + relations: [ + refines(specId("spec:orders.anchored-parent")), + verifies(specId("spec:orders.anchored-parent")), + ], +}); diff --git a/test/fixtures/extract/dangling-anchor/dangling-binding.ts.txt b/test/fixtures/extract/dangling-anchor/dangling-binding.ts.txt new file mode 100644 index 0000000..953a56c --- /dev/null +++ b/test/fixtures/extract/dangling-anchor/dangling-binding.ts.txt @@ -0,0 +1,9 @@ +import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; + +// The satisfies target resolves to no spec: the edge is emitted (the unresolved id is the +// sentinel), the referential-integrity check fails it over the one graph, and no implemented +// fact is conferred — resolution gates the delivery fact. +export const danglingBindingAnchor = codeAnchor({ + id: codeAnchorId("impl:orders.dangling-binding"), + satisfies: ref("spec:orders.missing-implementation-target"), +}); diff --git a/test/fixtures/extract/dangling-relation/dangling-relation.sdp.ts.txt b/test/fixtures/extract/dangling-relation/dangling-relation.sdp.ts.txt new file mode 100644 index 0000000..65757df --- /dev/null +++ b/test/fixtures/extract/dangling-relation/dangling-relation.sdp.ts.txt @@ -0,0 +1,13 @@ +import { refines, spec, specId } from "@libar-dev/software-delivery-protocol"; + +// A dangling target is emitted, not dropped: the unresolved id itself is the sentinel in the +// graph, and the referential-integrity check fails it over the one graph (sdp validate). +export const danglingRelationSpec = spec({ + id: specId("spec:orders.dangling-relation"), + title: "Spec whose relation target is missing", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "Emit the dangling edge and let referential integrity flag it." }, + relations: [refines(specId("spec:orders.missing-target"))], +}); diff --git a/test/fixtures/extract/default-import/default-binding.ts.txt b/test/fixtures/extract/default-import/default-binding.ts.txt new file mode 100644 index 0000000..e329d90 --- /dev/null +++ b/test/fixtures/extract/default-import/default-binding.ts.txt @@ -0,0 +1,8 @@ +import sdp from "@libar-dev/software-delivery-protocol"; + +// The package ships no default export, but an interop consumer can still author through a +// default import — the binding must extract exactly as the namespace spelling does (L2). +export const defaultImportBinding = sdp.codeAnchor({ + id: sdp.codeAnchorId("impl:orders.default-import-surface"), + satisfies: sdp.ref("spec:orders.default-import-parent"), +}); diff --git a/test/fixtures/extract/dot-directory-skipped/.history/surface-binding.ts.txt b/test/fixtures/extract/dot-directory-skipped/.history/surface-binding.ts.txt new file mode 100644 index 0000000..76f4fb3 --- /dev/null +++ b/test/fixtures/extract/dot-directory-skipped/.history/surface-binding.ts.txt @@ -0,0 +1,7 @@ +import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; + +// The `.history` twin of this file is the stray copy discovery must skip (see surface-spec). +export const surfaceBinding = codeAnchor({ + id: codeAnchorId("impl:orders.dot-directory-binding"), + satisfies: ref("spec:orders.dot-directory-surface"), +}); diff --git a/test/fixtures/extract/dot-directory-skipped/.history/surface-spec.sdp.ts.txt b/test/fixtures/extract/dot-directory-skipped/.history/surface-spec.sdp.ts.txt new file mode 100644 index 0000000..1b484a8 --- /dev/null +++ b/test/fixtures/extract/dot-directory-skipped/.history/surface-spec.sdp.ts.txt @@ -0,0 +1,11 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +// The `.history` twin of this file is the stray copy discovery must skip: no authoring surface +// lives in a dot-directory, and descending would raise duplicate-id hard errors. +export const surfaceSpec = spec({ + id: specId("spec:orders.dot-directory-surface"), + title: "Spec on the real authoring surface", + kind: "behavior", + altitude: "story", + readiness: "idea", +}); diff --git a/test/fixtures/extract/dot-directory-skipped/surface-binding.ts.txt b/test/fixtures/extract/dot-directory-skipped/surface-binding.ts.txt new file mode 100644 index 0000000..76f4fb3 --- /dev/null +++ b/test/fixtures/extract/dot-directory-skipped/surface-binding.ts.txt @@ -0,0 +1,7 @@ +import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; + +// The `.history` twin of this file is the stray copy discovery must skip (see surface-spec). +export const surfaceBinding = codeAnchor({ + id: codeAnchorId("impl:orders.dot-directory-binding"), + satisfies: ref("spec:orders.dot-directory-surface"), +}); diff --git a/test/fixtures/extract/dot-directory-skipped/surface-spec.sdp.ts.txt b/test/fixtures/extract/dot-directory-skipped/surface-spec.sdp.ts.txt new file mode 100644 index 0000000..1b484a8 --- /dev/null +++ b/test/fixtures/extract/dot-directory-skipped/surface-spec.sdp.ts.txt @@ -0,0 +1,11 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +// The `.history` twin of this file is the stray copy discovery must skip: no authoring surface +// lives in a dot-directory, and descending would raise duplicate-id hard errors. +export const surfaceSpec = spec({ + id: specId("spec:orders.dot-directory-surface"), + title: "Spec on the real authoring surface", + kind: "behavior", + altitude: "story", + readiness: "idea", +}); diff --git a/test/fixtures/extract/duplicate-anchor-id/first-binding.ts.txt b/test/fixtures/extract/duplicate-anchor-id/first-binding.ts.txt new file mode 100644 index 0000000..1210473 --- /dev/null +++ b/test/fixtures/extract/duplicate-anchor-id/first-binding.ts.txt @@ -0,0 +1,6 @@ +import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; + +export const firstDuplicateBinding = codeAnchor({ + id: codeAnchorId("impl:orders.duplicate-binding"), + satisfies: ref("spec:orders.duplicate-target"), +}); diff --git a/test/fixtures/extract/duplicate-anchor-id/second-binding.ts.txt b/test/fixtures/extract/duplicate-anchor-id/second-binding.ts.txt new file mode 100644 index 0000000..f838ef9 --- /dev/null +++ b/test/fixtures/extract/duplicate-anchor-id/second-binding.ts.txt @@ -0,0 +1,8 @@ +import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; + +// The same anchor id reified from a second site (L2 — ambiguity is loud): both sites are +// reported and neither enters the graph. +export const secondDuplicateBinding = codeAnchor({ + id: codeAnchorId("impl:orders.duplicate-binding"), + satisfies: ref("spec:orders.duplicate-target"), +}); diff --git a/test/fixtures/extract/duplicate-id/first-site.sdp.ts.txt b/test/fixtures/extract/duplicate-id/first-site.sdp.ts.txt new file mode 100644 index 0000000..b20a8c3 --- /dev/null +++ b/test/fixtures/extract/duplicate-id/first-site.sdp.ts.txt @@ -0,0 +1,11 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +// One of two files reifying the same id — ambiguity is loud (L2), and both sites are reported. +export const duplicateFirstSite = spec({ + id: specId("spec:orders.duplicate"), + title: "First site of a duplicated id", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "Collide with the sibling file's id." }, +}); diff --git a/test/fixtures/extract/duplicate-id/second-site.sdp.ts.txt b/test/fixtures/extract/duplicate-id/second-site.sdp.ts.txt new file mode 100644 index 0000000..e3d3cdd --- /dev/null +++ b/test/fixtures/extract/duplicate-id/second-site.sdp.ts.txt @@ -0,0 +1,10 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const duplicateSecondSite = spec({ + id: specId("spec:orders.duplicate"), + title: "Second site of a duplicated id", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "Collide with the sibling file's id." }, +}); diff --git a/test/fixtures/extract/duplicate-section-property/duplicate-nested.sdp.ts.txt b/test/fixtures/extract/duplicate-section-property/duplicate-nested.sdp.ts.txt new file mode 100644 index 0000000..ac425f2 --- /dev/null +++ b/test/fixtures/extract/duplicate-section-property/duplicate-nested.sdp.ts.txt @@ -0,0 +1,13 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +// The duplicate-property guard at the section tier: the repeat drops with a warning and the +// first authored value survives — never a silent last-wins (tsc reports TS1117 to typechecking +// authors; the extractor reads files standalone, so it is the backstop at every object tier). +export const nestedDuplicate = spec({ + id: specId("spec:orders.nested-duplicate"), + title: "Duplicate property inside section content", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "first authored value", outcome: "silent last-wins would take this" }, +}); diff --git a/test/fixtures/extract/id-shaped-string-content/id-shaped-string.sdp.ts.txt b/test/fixtures/extract/id-shaped-string-content/id-shaped-string.sdp.ts.txt new file mode 100644 index 0000000..2a12948 --- /dev/null +++ b/test/fixtures/extract/id-shaped-string-content/id-shaped-string.sdp.ts.txt @@ -0,0 +1,15 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +// The MD-10 guard covers the typed affordance (`ref(…)`) only: a raw id-shaped string in section +// content is prose by definition — it survives as content, names no edge, and is never validated, +// exactly as any other sentence mentioning a spec is. This corpus pins that boundary. +export const idShapedStringSpec = spec({ + id: specId("spec:orders.id-shaped-string"), + title: "Spec whose section prose mentions a spec id", + kind: "behavior", + altitude: "story", + readiness: "idea", + behavior: { + examples: ["spec:orders.promoted-child"], + }, +}); diff --git a/test/fixtures/extract/invalid-anchor-namespace/wrong-namespace-anchor.ts.txt b/test/fixtures/extract/invalid-anchor-namespace/wrong-namespace-anchor.ts.txt new file mode 100644 index 0000000..cc8f06d --- /dev/null +++ b/test/fixtures/extract/invalid-anchor-namespace/wrong-namespace-anchor.ts.txt @@ -0,0 +1,8 @@ +import { codeAnchor, ref } from "@libar-dev/software-delivery-protocol"; + +// A code anchor carrying a test: id — a static id, but outside the slot's namespaces +// (impl · api · component), so the graph is never keyed on it: extract/invalid-id. +export const wrongNamespaceAnchor = codeAnchor({ + id: "test:orders.smuggled-into-code-anchor", + satisfies: ref("spec:orders.anything"), +}); diff --git a/test/fixtures/extract/invalid-duplicate-pack-member/duplicated-membership.pack.sdp.ts.txt b/test/fixtures/extract/invalid-duplicate-pack-member/duplicated-membership.pack.sdp.ts.txt new file mode 100644 index 0000000..0c211a1 --- /dev/null +++ b/test/fixtures/extract/invalid-duplicate-pack-member/duplicated-membership.pack.sdp.ts.txt @@ -0,0 +1,9 @@ +import { pack, packId, ref } from "@libar-dev/software-delivery-protocol"; + +// Lists the same member twice: membership is single-sourced on the manifest, and a duplicate is +// ambiguous (L2) — the pack-coherence check fails it. +export const duplicatedMembershipPack = pack({ + id: packId("pack:checkout-v1"), + title: "Checkout v1", + specs: [ref("spec:orders.create-order"), ref("spec:orders.create-order")], +}); diff --git a/test/fixtures/extract/invalid-duplicate-pack-member/member.sdp.ts.txt b/test/fixtures/extract/invalid-duplicate-pack-member/member.sdp.ts.txt new file mode 100644 index 0000000..abe6038 --- /dev/null +++ b/test/fixtures/extract/invalid-duplicate-pack-member/member.sdp.ts.txt @@ -0,0 +1,10 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const createOrderSpec = spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "Turn a valid cart into an order." }, +}); diff --git a/test/fixtures/extract/invalid-duplicate-property/duplicate-id-property.sdp.ts.txt b/test/fixtures/extract/invalid-duplicate-property/duplicate-id-property.sdp.ts.txt new file mode 100644 index 0000000..6ad15f9 --- /dev/null +++ b/test/fixtures/extract/invalid-duplicate-property/duplicate-id-property.sdp.ts.txt @@ -0,0 +1,13 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +// Two id properties in one carrier: evaluation would keep the last while diagnostics key on the +// first — ambiguity the extractor must reject loudly (tsc reports TS1117 only to typechecking +// authors; the extractor reads files standalone, so it is the backstop). +export const duplicateIdSpec = spec({ + id: specId("spec:orders.first-authored-id"), + title: "Carrier with a repeated id property", + kind: "behavior", + altitude: "story", + readiness: "idea", + id: specId("spec:orders.last-authored-id"), +}); diff --git a/test/fixtures/extract/invalid-duplicate-property/duplicate-target-property.ts.txt b/test/fixtures/extract/invalid-duplicate-property/duplicate-target-property.ts.txt new file mode 100644 index 0000000..8ee3a90 --- /dev/null +++ b/test/fixtures/extract/invalid-duplicate-property/duplicate-target-property.ts.txt @@ -0,0 +1,7 @@ +import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; + +export const duplicateTargetBinding = codeAnchor({ + id: codeAnchorId("impl:orders.duplicate-target-binding"), + satisfies: ref("spec:orders.first-target"), + satisfies: ref("spec:orders.last-target"), +}); diff --git a/test/fixtures/extract/invalid-hand-authored-delivery-fact-in-section/delivery-fact-in-section.sdp.ts.txt b/test/fixtures/extract/invalid-hand-authored-delivery-fact-in-section/delivery-fact-in-section.sdp.ts.txt new file mode 100644 index 0000000..581254a --- /dev/null +++ b/test/fixtures/extract/invalid-hand-authored-delivery-fact-in-section/delivery-fact-in-section.sdp.ts.txt @@ -0,0 +1,15 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +// Hand-authors a delivery fact inside section content. Typed sections reject this shape at tsc +// time for real authors (MD-11) — this corpus is committed defused and never typechecks, which is +// exactly the path the graph-level authoring-shape honesty check closes (MD-16): the extractor +// reifies section interiors as content, and the check fails the smuggled key over the graph. +export const dishonestSpec = spec({ + id: specId("spec:orders.dishonest-section"), + title: "Spec smuggling a delivery fact through a section", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "Pretend to carry has-verifier without any binding." }, + behavior: { rules: ["Only valid carts become orders."], "has-verifier": true }, +}); diff --git a/test/fixtures/extract/invalid-hand-authored-satisfies-edge/hand-authored-satisfies-edge.sdp.ts.txt b/test/fixtures/extract/invalid-hand-authored-satisfies-edge/hand-authored-satisfies-edge.sdp.ts.txt new file mode 100644 index 0000000..a44ab64 --- /dev/null +++ b/test/fixtures/extract/invalid-hand-authored-satisfies-edge/hand-authored-satisfies-edge.sdp.ts.txt @@ -0,0 +1,19 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +// The extraction-layer twin of authoring-shape honesty: a raw relations[] entry smuggling a +// derived satisfies edge (and a foreign claim) is rejected at the envelope tier. This file is +// deliberately not type-correct — like every extraction corpus it is committed with a .txt +// suffix so the repo itself never carries an invalid *.sdp.ts (discovery would sweep it into any +// build rooted above it); the corpus test materializes it back to its real name in a temp +// directory before extraction. +export const handAuthoredSatisfiesEdgeSpec = spec({ + id: specId("spec:orders.hand-authored-satisfies-edge"), + title: "Spec smuggling a raw satisfies edge", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "Pin the raw relations-entry envelope error." }, + relations: [ + { type: "satisfies", target: specId("spec:orders.static-sibling"), claim: "anchored" }, + ], +}); diff --git a/test/fixtures/extract/invalid-malformed-id/malformed-id.sdp.ts.txt b/test/fixtures/extract/invalid-malformed-id/malformed-id.sdp.ts.txt new file mode 100644 index 0000000..abcc135 --- /dev/null +++ b/test/fixtures/extract/invalid-malformed-id/malformed-id.sdp.ts.txt @@ -0,0 +1,12 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +// Static but malformed: the id reifies fine and then fails the parseId grammar (missing path) — +// the graph is never keyed on a malformed id. +export const malformedIdSpec = spec({ + id: specId("spec:"), + title: "Spec whose static id fails the id grammar", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "Pin the invalid-id hard error." }, +}); diff --git a/test/fixtures/extract/invalid-missing-envelope-fields/bare-binding.ts.txt b/test/fixtures/extract/invalid-missing-envelope-fields/bare-binding.ts.txt new file mode 100644 index 0000000..a1d63f1 --- /dev/null +++ b/test/fixtures/extract/invalid-missing-envelope-fields/bare-binding.ts.txt @@ -0,0 +1,3 @@ +import { codeAnchor } from "@libar-dev/software-delivery-protocol"; + +export const bareBinding = codeAnchor({}); diff --git a/test/fixtures/extract/invalid-missing-envelope-fields/bare-pack.sdp.ts.txt b/test/fixtures/extract/invalid-missing-envelope-fields/bare-pack.sdp.ts.txt new file mode 100644 index 0000000..bdf72f3 --- /dev/null +++ b/test/fixtures/extract/invalid-missing-envelope-fields/bare-pack.sdp.ts.txt @@ -0,0 +1,3 @@ +import { pack } from "@libar-dev/software-delivery-protocol"; + +export const barePack = pack({}); diff --git a/test/fixtures/extract/invalid-missing-envelope-fields/bare-spec.sdp.ts.txt b/test/fixtures/extract/invalid-missing-envelope-fields/bare-spec.sdp.ts.txt new file mode 100644 index 0000000..869ca27 --- /dev/null +++ b/test/fixtures/extract/invalid-missing-envelope-fields/bare-spec.sdp.ts.txt @@ -0,0 +1,5 @@ +import { spec } from "@libar-dev/software-delivery-protocol"; + +// Every absent required envelope field must surface in one extraction pass — never drip-fed one +// finding per run. +export const bareSpec = spec({}); diff --git a/test/fixtures/extract/invalid-non-model-modelref/member.sdp.ts.txt b/test/fixtures/extract/invalid-non-model-modelref/member.sdp.ts.txt new file mode 100644 index 0000000..abe6038 --- /dev/null +++ b/test/fixtures/extract/invalid-non-model-modelref/member.sdp.ts.txt @@ -0,0 +1,10 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const createOrderSpec = spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "Turn a valid cart into an order." }, +}); diff --git a/test/fixtures/extract/invalid-non-model-modelref/wrong-kind-modelref.pack.sdp.ts.txt b/test/fixtures/extract/invalid-non-model-modelref/wrong-kind-modelref.pack.sdp.ts.txt new file mode 100644 index 0000000..5981582 --- /dev/null +++ b/test/fixtures/extract/invalid-non-model-modelref/wrong-kind-modelref.pack.sdp.ts.txt @@ -0,0 +1,10 @@ +import { pack, packId, ref } from "@libar-dev/software-delivery-protocol"; + +// modelRefs name the pack's model-kind vocabulary specs; this one targets a behavior spec — +// the pack-coherence check fails the resolving-but-wrong-kind reference. +export const wrongKindModelRefPack = pack({ + id: packId("pack:checkout-v1"), + title: "Checkout v1", + specs: [ref("spec:orders.create-order")], + modelRefs: [ref("spec:orders.create-order")], +}); diff --git a/test/fixtures/extract/invalid-non-static-anchor/non-static-anchor.ts.txt b/test/fixtures/extract/invalid-non-static-anchor/non-static-anchor.ts.txt new file mode 100644 index 0000000..1209437 --- /dev/null +++ b/test/fixtures/extract/invalid-non-static-anchor/non-static-anchor.ts.txt @@ -0,0 +1,14 @@ +import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; + +// Envelope hard error: the satisfies target is assembled through a substitution template, so the +// binding identity is non-static — the anchor is not extracted and the build fails. +export const nonStaticTargetAnchor = codeAnchor({ + id: codeAnchorId("impl:orders.non-static-target"), + satisfies: ref(`spec:orders.${"assembled"}`), +}); + +// The static sibling: one bad anchor never poisons the rest of the corpus (L3). +export const staticSiblingAnchor = codeAnchor({ + id: codeAnchorId("impl:orders.static-sibling-binding"), + satisfies: ref("spec:orders.static-anchor-target"), +}); diff --git a/test/fixtures/extract/invalid-non-static-anchor/target.sdp.ts.txt b/test/fixtures/extract/invalid-non-static-anchor/target.sdp.ts.txt new file mode 100644 index 0000000..7f38d8f --- /dev/null +++ b/test/fixtures/extract/invalid-non-static-anchor/target.sdp.ts.txt @@ -0,0 +1,10 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const staticAnchorTargetSpec = spec({ + id: specId("spec:orders.static-anchor-target"), + title: "Target of the surviving sibling anchor", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "Resolve the surviving binding." }, +}); diff --git a/test/fixtures/extract/invalid-non-static-id/non-static-id.sdp.ts.txt b/test/fixtures/extract/invalid-non-static-id/non-static-id.sdp.ts.txt new file mode 100644 index 0000000..d437e24 --- /dev/null +++ b/test/fixtures/extract/invalid-non-static-id/non-static-id.sdp.ts.txt @@ -0,0 +1,11 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +// Envelope hard error: the id is assembled through a substitution template, so it is non-static. +export const nonStaticIdSpec = spec({ + id: specId(`spec:orders.${"non-static"}`), + title: "Spec whose id is assembled at runtime", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "Pin the non-static envelope hard error." }, +}); diff --git a/test/fixtures/extract/invalid-non-static-id/static-sibling.sdp.ts.txt b/test/fixtures/extract/invalid-non-static-id/static-sibling.sdp.ts.txt new file mode 100644 index 0000000..b1338ba --- /dev/null +++ b/test/fixtures/extract/invalid-non-static-id/static-sibling.sdp.ts.txt @@ -0,0 +1,11 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +// The valid sibling: one bad spec never poisons the rest of the corpus (L3). +export const staticSiblingSpec = spec({ + id: specId("spec:orders.static-sibling"), + title: "Static sibling in the same corpus", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "Survive extraction beside a hard-error sibling." }, +}); diff --git a/test/fixtures/extract/invalid-non-static-section/non-static-section.sdp.ts.txt b/test/fixtures/extract/invalid-non-static-section/non-static-section.sdp.ts.txt new file mode 100644 index 0000000..ff6b751 --- /dev/null +++ b/test/fixtures/extract/invalid-non-static-section/non-static-section.sdp.ts.txt @@ -0,0 +1,15 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +// Section-tier degradation: intent.value is computed, so that one property drops with a warning +// while the rest of the spec survives (graceful partial extraction, L3). +export const nonStaticSectionSpec = spec({ + id: specId("spec:orders.non-static-section"), + title: "Spec with one non-static section property", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { + outcome: "Survive extraction with only the non-static property dropped.", + value: ["Computed", "at runtime."].join(" "), + }, +}); diff --git a/test/fixtures/extract/invalid-parse-error/healthy-sibling.sdp.ts.txt b/test/fixtures/extract/invalid-parse-error/healthy-sibling.sdp.ts.txt new file mode 100644 index 0000000..9c3bb37 --- /dev/null +++ b/test/fixtures/extract/invalid-parse-error/healthy-sibling.sdp.ts.txt @@ -0,0 +1,9 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const healthySiblingSpec = spec({ + id: specId("spec:orders.healthy-sibling"), + title: "Sibling file untouched by the parse break next door", + kind: "behavior", + altitude: "story", + readiness: "idea", +}); diff --git a/test/fixtures/extract/invalid-parse-error/parse-broken-binding.ts.txt b/test/fixtures/extract/invalid-parse-error/parse-broken-binding.ts.txt new file mode 100644 index 0000000..adcec20 --- /dev/null +++ b/test/fixtures/extract/invalid-parse-error/parse-broken-binding.ts.txt @@ -0,0 +1,11 @@ +import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; + +// The binding itself is well-formed; the parse break further down poisons the file, and a binding +// in a parse-broken code file must fail loudly rather than ride an untrustworthy AST into the +// graph (L2). +export const parseBrokenBinding = codeAnchor({ + id: codeAnchorId("impl:orders.parse-broken-binding"), + satisfies: ref("spec:orders.healthy-sibling"), +}); + +export const danglingProse = `unterminated diff --git a/test/fixtures/extract/invalid-parse-error/parse-broken.sdp.ts.txt b/test/fixtures/extract/invalid-parse-error/parse-broken.sdp.ts.txt new file mode 100644 index 0000000..8128596 --- /dev/null +++ b/test/fixtures/extract/invalid-parse-error/parse-broken.sdp.ts.txt @@ -0,0 +1,21 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +// The unterminated template literal below is the parse break: an error-tolerant parse would +// swallow the second carrier into the first one's section prose, so the whole file must fail +// loudly instead of reifying anything (ambiguity is loud, L2). +export const swallowingSpec = spec({ + id: specId("spec:orders.parse-broken"), + title: "Carrier whose section prose never terminates", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: `unterminated +}); + +export const swallowedSpec = spec({ + id: specId("spec:orders.swallowed"), + title: "Carrier the recovered AST would silently absorb", + kind: "behavior", + altitude: "story", + readiness: "idea", +}); diff --git a/test/fixtures/extract/invalid-ready-with-target-below-defined/ready-with-scoped-dependency.sdp.ts.txt b/test/fixtures/extract/invalid-ready-with-target-below-defined/ready-with-scoped-dependency.sdp.ts.txt new file mode 100644 index 0000000..c8ff890 --- /dev/null +++ b/test/fixtures/extract/invalid-ready-with-target-below-defined/ready-with-scoped-dependency.sdp.ts.txt @@ -0,0 +1,35 @@ +import { dependsOn, refines, spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const paymentHandlingSpec = spec({ + id: specId("spec:payments.payment-handling"), + title: "Payment handling", + kind: "behavior", + altitude: "epic", + readiness: "idea", + intent: { outcome: "Own payment authorization for checkout." }, +}); + +// The dependency clears its own scoped floor but states only scoped — below defined. +export const authorizePaymentSpec = spec({ + id: specId("spec:payments.authorize-payment"), + title: "Authorize payment", + kind: "behavior", + altitude: "feature", + readiness: "scoped", + intent: { outcome: "Authorize a payment before order creation." }, + behavior: { examples: ["A valid card authorizes the cart total."] }, + relations: [refines(specId("spec:payments.payment-handling"))], +}); + +// States ready while depending on a scoped target: every reference resolves, so the one floor +// failure is depends-on-and-refines-targets-are-defined — the glossary's worked dialogue, pinned. +export const createOrderSpec = spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "ready", + intent: { outcome: "Turn a valid cart into an order." }, + behavior: { rules: ["Only valid carts become orders."] }, + relations: [dependsOn(specId("spec:payments.authorize-payment"))], +}); diff --git a/test/fixtures/extract/invalid-ready-with-unresolved-dependency/ready-with-unresolved-dependency.sdp.ts.txt b/test/fixtures/extract/invalid-ready-with-unresolved-dependency/ready-with-unresolved-dependency.sdp.ts.txt new file mode 100644 index 0000000..48cb266 --- /dev/null +++ b/test/fixtures/extract/invalid-ready-with-unresolved-dependency/ready-with-unresolved-dependency.sdp.ts.txt @@ -0,0 +1,16 @@ +import { dependsOn, spec, specId } from "@libar-dev/software-delivery-protocol"; + +// States ready while its dependency target resolves nowhere in the graph. Two findings by design +// (two families, two statements): the referential-integrity error names the broken reference, and +// the floor clause all-relations-resolve names the unearned ready. The target-rung clause stays +// silent — it evaluates resolving targets only. +export const createOrderSpec = spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "ready", + intent: { outcome: "Turn a valid cart into an order." }, + behavior: { rules: ["Only valid carts become orders."] }, + relations: [dependsOn(specId("spec:payments.authorize-payment"))], +}); diff --git a/test/fixtures/extract/invalid-reserved-property/reserved-property.sdp.ts.txt b/test/fixtures/extract/invalid-reserved-property/reserved-property.sdp.ts.txt new file mode 100644 index 0000000..7e75870 --- /dev/null +++ b/test/fixtures/extract/invalid-reserved-property/reserved-property.sdp.ts.txt @@ -0,0 +1,13 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +// Envelope hard error: a hand-authored piece of derived graph vocabulary impersonates machine +// truth (the extraction-layer twin of authoring-shape honesty), so the spec is not extracted. +export const reservedPropertySpec = spec({ + id: specId("spec:orders.reserved-property"), + title: "Spec hand-authoring derived graph vocabulary", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "Pin the reserved-property hard error." }, + deliveryFacts: ["implemented"], +}); diff --git a/test/fixtures/extract/invalid-reserved-property/static-sibling.sdp.ts.txt b/test/fixtures/extract/invalid-reserved-property/static-sibling.sdp.ts.txt new file mode 100644 index 0000000..02f4988 --- /dev/null +++ b/test/fixtures/extract/invalid-reserved-property/static-sibling.sdp.ts.txt @@ -0,0 +1,11 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +// The valid sibling: one bad spec never poisons the rest of the corpus (L3). +export const reservedStaticSiblingSpec = spec({ + id: specId("spec:orders.reserved-static-sibling"), + title: "Static sibling in the same corpus", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "Survive extraction beside a hard-error sibling." }, +}); diff --git a/test/fixtures/extract/invalid-wrong-builder/wrong-builder.sdp.ts.txt b/test/fixtures/extract/invalid-wrong-builder/wrong-builder.sdp.ts.txt new file mode 100644 index 0000000..e8c9b46 --- /dev/null +++ b/test/fixtures/extract/invalid-wrong-builder/wrong-builder.sdp.ts.txt @@ -0,0 +1,18 @@ +import { pack, packId, ref, spec, specId } from "@libar-dev/software-delivery-protocol"; + +// Each id wraps the wrong builder for its namespace: the slot namespace is satisfied, but the +// builder's own contract is not — evaluation would throw, and tsc rejects the branded +// assignment, so the extractor (reading standalone) must restate the check statically. +export const wrongBuilderPack = pack({ + id: specId("pack:checkout-v1"), + title: "Pack id wrapped in specId", + specs: [ref("spec:orders.create-order")], +}); + +export const wrongBuilderSpec = spec({ + id: packId("spec:orders.wrong-builder"), + title: "Spec id wrapped in packId", + kind: "behavior", + altitude: "feature", + readiness: "idea", +}); diff --git a/test/fixtures/extract/misplaced-anchor/misplaced-authoring.ts.txt b/test/fixtures/extract/misplaced-anchor/misplaced-authoring.ts.txt new file mode 100644 index 0000000..3918274 --- /dev/null +++ b/test/fixtures/extract/misplaced-anchor/misplaced-authoring.ts.txt @@ -0,0 +1,21 @@ +import { codeAnchor, codeAnchorId, ref, spec, specId } from "@libar-dev/software-delivery-protocol"; + +// An anchor call buried in a function body is outside the anchor-constant form: it warns loudly +// and is not extracted — a binding the author believes exists must never silently fall out of +// the graph (L2). +export function registerBindings() { + return codeAnchor({ + id: codeAnchorId("impl:orders.buried-binding"), + satisfies: ref("spec:orders.create-order"), + }); +} + +// A spec() call in a non-.sdp.ts source file is equally misplaced: specs are extracted from +// *.sdp.ts files only (the .sdp.ts extension, MD-15). +export const misplacedSpec = spec({ + id: specId("spec:orders.misplaced-in-source"), + title: "Spec authored outside the spec surface", + kind: "behavior", + altitude: "story", + readiness: "idea", +}); diff --git a/test/fixtures/extract/namespace-import/namespace-authored.sdp.ts.txt b/test/fixtures/extract/namespace-import/namespace-authored.sdp.ts.txt new file mode 100644 index 0000000..40f6127 --- /dev/null +++ b/test/fixtures/extract/namespace-import/namespace-authored.sdp.ts.txt @@ -0,0 +1,24 @@ +import * as sdp from "@libar-dev/software-delivery-protocol"; + +// The namespace import is one of the recognized import forms (named · namespace · default): +// `sdp.spec(…)` must extract exactly as the named-import spelling does. +export const namespaceParentSpec = sdp.spec({ + id: sdp.specId("spec:orders.namespace-parent"), + title: "Parent authored through the namespace import", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "Extract through the property-access builder spelling." }, +}); + +export const namespaceExampleSpec = sdp.spec({ + id: sdp.specId("spec:orders.namespace-parent.example"), + title: "Example authored through the namespace import", + kind: "example", + altitude: "story", + readiness: "idea", + relations: [ + sdp.refines(sdp.specId("spec:orders.namespace-parent")), + sdp.verifies(sdp.specId("spec:orders.namespace-parent")), + ], +}); diff --git a/test/fixtures/extract/namespace-import/namespace-binding.ts.txt b/test/fixtures/extract/namespace-import/namespace-binding.ts.txt new file mode 100644 index 0000000..e3e3f61 --- /dev/null +++ b/test/fixtures/extract/namespace-import/namespace-binding.ts.txt @@ -0,0 +1,12 @@ +import * as sdp from "@libar-dev/software-delivery-protocol"; + +export const namespaceBinding = sdp.codeAnchor({ + id: sdp.codeAnchorId("impl:orders.namespace-binding"), + label: "namespaceBinding", + satisfies: sdp.ref("spec:orders.namespace-parent"), +}); + +export const namespaceTestBinding = sdp.specTest({ + id: sdp.testAnchorId("test:orders.namespace-parent.example"), + verifies: sdp.ref("spec:orders.namespace-parent.example"), +}); diff --git a/test/fixtures/extract/namespace-misplaced-authoring/namespace-misplaced.ts.txt b/test/fixtures/extract/namespace-misplaced-authoring/namespace-misplaced.ts.txt new file mode 100644 index 0000000..793a032 --- /dev/null +++ b/test/fixtures/extract/namespace-misplaced-authoring/namespace-misplaced.ts.txt @@ -0,0 +1,21 @@ +import * as sdp from "@libar-dev/software-delivery-protocol"; + +// The misplaced-authoring sweep must see the property-access spelling too: a buried namespace +// anchor call warns exactly as the named-import form does — a binding the author believes exists +// must never silently fall out of the graph (L2). +export function registerNamespaceBinding() { + return sdp.codeAnchor({ + id: sdp.codeAnchorId("impl:orders.namespace-buried-binding"), + satisfies: sdp.ref("spec:orders.create-order"), + }); +} + +// A namespace-authored spec() in a non-.sdp.ts source file is equally misplaced: specs are +// extracted from *.sdp.ts files only (the .sdp.ts extension, MD-15). +export const namespaceMisplacedSpec = sdp.spec({ + id: sdp.specId("spec:orders.namespace-misplaced"), + title: "Spec authored through the namespace import outside the spec surface", + kind: "behavior", + altitude: "story", + readiness: "idea", +}); diff --git a/test/fixtures/extract/non-example-verifier/workflow-verifier.sdp.ts.txt b/test/fixtures/extract/non-example-verifier/workflow-verifier.sdp.ts.txt new file mode 100644 index 0000000..d15a86d --- /dev/null +++ b/test/fixtures/extract/non-example-verifier/workflow-verifier.sdp.ts.txt @@ -0,0 +1,23 @@ +import { spec, specId, verifies } from "@libar-dev/software-delivery-protocol"; + +export const createOrderSpec = spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "Turn a valid cart into an order." }, +}); + +// A workflow spec declaring verifies: the relation is authored freely, but only an +// example/scenario can be an enabled verifier (MD-7), so it confers no has-verifier — the +// verifies-linkage check surfaces it informatively, never as a gate. +export const reconciliationWorkflowSpec = spec({ + id: specId("spec:orders.reconciliation-workflow"), + title: "Reconciliation workflow claims to verify order creation", + kind: "workflow", + altitude: "story", + readiness: "idea", + intent: { outcome: "Show a non-example verifier surfacing informatively." }, + relations: [verifies(specId("spec:orders.create-order"))], +}); diff --git a/test/fixtures/extract/non-static-anchor-label/non-static-label.ts.txt b/test/fixtures/extract/non-static-anchor-label/non-static-label.ts.txt new file mode 100644 index 0000000..0ad3559 --- /dev/null +++ b/test/fixtures/extract/non-static-anchor-label/non-static-label.ts.txt @@ -0,0 +1,9 @@ +import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; + +// The label is degradable detail: it drops with a warning while the binding itself — identity +// and target — survives whole. +export const nonStaticLabelAnchor = codeAnchor({ + id: codeAnchorId("impl:orders.non-static-label"), + label: `computed ${"label"}`, + satisfies: ref("spec:orders.labelled-target"), +}); diff --git a/test/fixtures/extract/opaque-envelope-entries/opaque-spec.sdp.ts.txt b/test/fixtures/extract/opaque-envelope-entries/opaque-spec.sdp.ts.txt new file mode 100644 index 0000000..08f405b --- /dev/null +++ b/test/fixtures/extract/opaque-envelope-entries/opaque-spec.sdp.ts.txt @@ -0,0 +1,23 @@ +import { spec } from "@libar-dev/software-delivery-protocol"; + +const id = "spec:orders.shorthand-authored"; + +// A shorthand entry still names its field: the carrier fails as non-static, but no field it +// names may be reported missing on top of that (a non-static field is not an absent one). +export const shorthandCarrier = spec({ + id, + kind: "behavior", + altitude: "feature", + readiness: "idea", +}); + +const base = { + id: "spec:orders.spread-authored", + title: "Spread-authored carrier", +} as const; + +// A spread is opaque — it could carry any field — so beside it nothing can honestly be called +// missing: exactly one finding (the non-static entry), never a false absence report per field. +export const spreadCarrier = spec({ + ...base, +}); diff --git a/test/fixtures/extract/orphan-spec/stranded.sdp.ts.txt b/test/fixtures/extract/orphan-spec/stranded.sdp.ts.txt new file mode 100644 index 0000000..25fba15 --- /dev/null +++ b/test/fixtures/extract/orphan-spec/stranded.sdp.ts.txt @@ -0,0 +1,12 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +// No relations and nothing pointing at it: fallen out of the graph's connective tissue — an +// orphan, surfaced informatively (a warning, never a gate). +export const strandedSpec = spec({ + id: specId("spec:orders.stranded"), + title: "Stranded spec with no connective tissue", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "Surface as an orphan, not fail the build." }, +}); diff --git a/test/fixtures/extract/ready-without-verifier/ready-rule.sdp.ts.txt b/test/fixtures/extract/ready-without-verifier/ready-rule.sdp.ts.txt new file mode 100644 index 0000000..20baaf8 --- /dev/null +++ b/test/fixtures/extract/ready-without-verifier/ready-rule.sdp.ts.txt @@ -0,0 +1,35 @@ +import { refines, spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const orderManagementSpec = spec({ + id: specId("spec:orders.order-management"), + title: "Order management", + kind: "behavior", + altitude: "epic", + readiness: "idea", + intent: { outcome: "Own the order lifecycle for checkout." }, +}); + +export const createOrderSpec = spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "defined", + intent: { outcome: "Turn a valid cart into an order." }, + behavior: { rules: ["Only valid carts become orders."] }, + relations: [refines(specId("spec:orders.order-management"))], +}); + +// Clears the whole ready floor — every reference resolves, the refines target is defined — but no +// verifier binds it: a gap (a surfaced absence), informative only, because ready never requires +// delivery facts. +export const orderTotalRuleSpec = spec({ + id: specId("spec:orders.order-total-rule"), + title: "Order total matches cart math", + kind: "rule", + altitude: "story", + readiness: "ready", + intent: { outcome: "Keep order totals deterministic." }, + behavior: { rules: ["The order total is the sum of all line subtotals."] }, + relations: [refines(specId("spec:orders.create-order"))], +}); diff --git a/test/fixtures/extract/ref-in-section-content/ref-in-behavior.sdp.ts.txt b/test/fixtures/extract/ref-in-section-content/ref-in-behavior.sdp.ts.txt new file mode 100644 index 0000000..513cdbc --- /dev/null +++ b/test/fixtures/extract/ref-in-section-content/ref-in-behavior.sdp.ts.txt @@ -0,0 +1,16 @@ +import { ref, spec, specId } from "@libar-dev/software-delivery-protocol"; + +// Sections carry content, relations carry linkage (MD-10): an id builder in section content is +// outside the static value grammar, so the owning property drops with a loud warning instead of +// surviving as a smuggled reference no referential check ever sees. +export const refInSectionSpec = spec({ + id: specId("spec:orders.ref-in-section"), + title: "Spec smuggling a ref through section content", + kind: "behavior", + altitude: "story", + readiness: "idea", + behavior: { + rules: ["A real rule survives beside the dropped property."], + examples: [ref("spec:orders.promoted-child")], + }, +}); diff --git a/test/fixtures/extract/shadowed-namespace-local/shadowed-binding.ts.txt b/test/fixtures/extract/shadowed-namespace-local/shadowed-binding.ts.txt new file mode 100644 index 0000000..dd21551 --- /dev/null +++ b/test/fixtures/extract/shadowed-namespace-local/shadowed-binding.ts.txt @@ -0,0 +1,22 @@ +import * as sdp from "@libar-dev/software-delivery-protocol"; + +export const shadowAnchor = sdp.codeAnchor({ + id: sdp.codeAnchorId("impl:orders.shadow-surface"), + satisfies: sdp.ref("spec:orders.shadow-parent"), +}); + +interface Toolkit { + codeAnchor(input: { id: string }): unknown; +} + +// The parameter shadows the namespace import: this call belongs to the Toolkit value, not the +// protocol, so the misplaced-authoring sweep must stay silent over it. +export function register(sdp: Toolkit): unknown { + return sdp.codeAnchor({ id: "not-a-protocol-anchor" }); +} + +// A block-scoped local shadows it too. +export function rebind(): unknown { + const sdp = { spec: (input: { id: string }) => input }; + return sdp.spec({ id: "not-a-protocol-spec" }); +} diff --git a/test/fixtures/extract/unenabled-verifier/declared-only-example.sdp.ts.txt b/test/fixtures/extract/unenabled-verifier/declared-only-example.sdp.ts.txt new file mode 100644 index 0000000..7471415 --- /dev/null +++ b/test/fixtures/extract/unenabled-verifier/declared-only-example.sdp.ts.txt @@ -0,0 +1,16 @@ +import { refines, spec, specId, verifies } from "@libar-dev/software-delivery-protocol"; + +// Declares verifies(parent) but no test anchor backs it anywhere in the corpus: not an enabled +// verifier, so the declared edge confers nothing (binding, never liveness — MD-7). +export const declaredOnlyExampleSpec = spec({ + id: specId("spec:orders.unverified-parent.example"), + title: "Example without a test binding", + kind: "example", + altitude: "story", + readiness: "idea", + intent: { outcome: "Show a declared verifies edge conferring no delivery fact." }, + relations: [ + refines(specId("spec:orders.unverified-parent")), + verifies(specId("spec:orders.unverified-parent")), + ], +}); diff --git a/test/fixtures/extract/unenabled-verifier/parent.sdp.ts.txt b/test/fixtures/extract/unenabled-verifier/parent.sdp.ts.txt new file mode 100644 index 0000000..5ca2707 --- /dev/null +++ b/test/fixtures/extract/unenabled-verifier/parent.sdp.ts.txt @@ -0,0 +1,10 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const unverifiedParentSpec = spec({ + id: specId("spec:orders.unverified-parent"), + title: "Parent whose only verifier is not enabled", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "Stay honestly free of has-verifier." }, +}); diff --git a/test/fixtures/extract/unrecognized-property/typoed-section.sdp.ts.txt b/test/fixtures/extract/unrecognized-property/typoed-section.sdp.ts.txt new file mode 100644 index 0000000..2b68242 --- /dev/null +++ b/test/fixtures/extract/unrecognized-property/typoed-section.sdp.ts.txt @@ -0,0 +1,15 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +// A typoed section name is outside the spec shape: that one property drops with a loud warning +// and the spec survives — authored content must never silently fall out of the graph (L2). +export const typoedSectionSpec = spec({ + id: specId("spec:orders.typoed-section"), + title: "Spec with a typoed section name", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "Warn loudly on a property outside the spec shape." }, + behaviour: { + rules: ["Content under the typo would have vanished from the graph without a finding."], + }, +}); diff --git a/test/fixtures/extract/unrecognized-statement/unrecognized-statement.sdp.ts.txt b/test/fixtures/extract/unrecognized-statement/unrecognized-statement.sdp.ts.txt new file mode 100644 index 0000000..fd792e7 --- /dev/null +++ b/test/fixtures/extract/unrecognized-statement/unrecognized-statement.sdp.ts.txt @@ -0,0 +1,15 @@ +import { spec, specId } from "@libar-dev/software-delivery-protocol"; + +export const recognizedSpec = spec({ + id: specId("spec:orders.recognized"), + title: "Recognized spec beside a stray statement", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "Extract cleanly while the stray statement warns." }, +}); + +// A statement outside the authored grammar: ignored, loudly (warning, never a hard error). +export function strayHelper(): string { + return "outside the authored grammar"; +} diff --git a/test/fixtures/authored-model.fixtures.ts b/test/fixtures/graph-validator.fixtures.ts similarity index 62% rename from test/fixtures/authored-model.fixtures.ts rename to test/fixtures/graph-validator.fixtures.ts index 53337b1..e5b97ea 100644 --- a/test/fixtures/authored-model.fixtures.ts +++ b/test/fixtures/graph-validator.fixtures.ts @@ -1,30 +1,39 @@ -import { constrainedBy, refines, spec, specId } from "../../src/index.js"; -import type { AuthoredModel } from "../../src/index.js"; +import { constrainedBy, pack, packId, refines, spec, specId } from "../../src/index.js"; +import type { FixtureModel } from "../helpers/fixture-graph.js"; /** - * Systematic should-pass / should-fail fixtures for the pre-graph authored-layer validators - * (`05` §5 "Validator self-testing"). Each fixture pins a single validator outcome so a regression - * that silently stops a validator firing is itself caught. + * Systematic should-pass / should-fail fixtures for the graph validators (`05` §5 "Validator + * self-testing"). Each fixture pins a single validator outcome so a regression that silently stops + * a validator firing is itself caught. Fixtures are authored with the DSL builders and derived + * through the real `deriveGraph` (see `helpers/fixture-graph.ts`); `validateGraph` consumes the + * result — the same seam every consumer uses (one validation path, MD-14). * - * The hand-authored-delivery-fact bypass is pinned twice, on purpose (the typing law MD-11 + + * The hand-authored-delivery-fact bypass is pinned three ways, on purpose (the typing law MD-11 + * carried evidence MD-16): the compile-time twin in `test/builders.typecheck.ts` proves the closed * section types reject it for inline literals; the runtime fixture here proves * `honesty/authoring-shape` catches the non-fresh object that slips past TypeScript's - * excess-property check. - * The extractor-era fixtures stay named, awaiting Slice 1+: `invalid-non-static-id` · - * `invalid-non-static-section` · `invalid-hand-authored-satisfies-edge` · - * `invalid-ready-with-unresolved-dependency` · `invalid-ready-with-target-below-defined`. + * excess-property check; and the on-disk corpus `invalid-hand-authored-delivery-fact-in-section` + * (`test/extract.test.ts`) proves the same end-to-end from a source file the typechecker never + * sees. The once-reserved extractor-era names are all active on-disk corpora: + * `invalid-non-static-id` · `invalid-non-static-section` · `invalid-hand-authored-satisfies-edge` + * · `invalid-ready-with-unresolved-dependency` · `invalid-ready-with-target-below-defined`. */ export interface ValidatorFixture { readonly name: string; - readonly model: AuthoredModel; + readonly model: FixtureModel; /** - * `"pass"` asserts `validateAuthoredModel` returns no findings. Otherwise the model must produce a - * finding from `validatorId`; when `relatedId` is given, at least one finding must also match it. + * `"pass"` asserts `validateGraph` returns no findings at all. Otherwise the model must produce + * a finding from `validatorId`; when `relatedId` and/or `path` is given, at least one finding + * must also match them (other informative findings may legitimately accompany a should-fail + * model). */ - readonly expect: "pass" | { readonly validatorId: string; readonly relatedId?: string }; + readonly expect: + | "pass" + | { readonly validatorId: string; readonly relatedId?: string; readonly path?: string }; } +// The minimal clean repo: one idea spec carried by a pack — connected (a lone spec would +// legitimately surface as an orphan, pinned by the `orphan-spec` corpus instead). const validMinimalIdeaSpec: ValidatorFixture = { name: "valid-minimal-idea-spec", model: { @@ -38,12 +47,20 @@ const validMinimalIdeaSpec: ValidatorFixture = { intent: { outcome: "Own the order lifecycle for checkout." }, }), ], - packs: [], - anchors: [], + packs: [ + pack({ + id: packId("pack:checkout-v1"), + title: "Checkout v1", + specs: [specId("spec:orders.order-management")], + }), + ], }, expect: "pass", }; +// The graph backstop for L2: the extractor excludes duplicate carriers before derivation +// (`extract/duplicate-id`), so this fixture deliberately reaches `validateGraph` with both nodes — +// the non-extractor-producer input class the graph check exists for. const invalidDuplicateId: ValidatorFixture = { name: "invalid-duplicate-id", model: { @@ -65,8 +82,6 @@ const invalidDuplicateId: ValidatorFixture = { intent: { outcome: "Accidental second definition of the same id." }, }), ], - packs: [], - anchors: [], }, expect: { validatorId: "conformance/duplicate-ids" }, }; @@ -86,8 +101,6 @@ const invalidScopedWithoutRelation: ValidatorFixture = { // No relations: a scoped spec must declare at least one authored relation. }), ], - packs: [], - anchors: [], }, expect: { validatorId: "honesty/readiness-floor", relatedId: "at-least-one-relation" }, }; @@ -117,8 +130,6 @@ const invalidDefinedConstraintWithoutTarget: ValidatorFixture = { relations: [refines(specId("spec:orders.create-order"))], }), ], - packs: [], - anchors: [], }, expect: { validatorId: "honesty/readiness-floor", @@ -156,8 +167,6 @@ const invalidReadyWithBlockingQuestion: ValidatorFixture = { relations: [refines(specId("spec:orders.order-management"))], }), ], - packs: [], - anchors: [], }, expect: { validatorId: "honesty/readiness-floor", relatedId: "no-blocking-open-questions" }, }; @@ -192,8 +201,6 @@ const validDefinedWithNonBlockingQuestion: ValidatorFixture = { relations: [refines(specId("spec:orders.order-management"))], }), ], - packs: [], - anchors: [], }, expect: "pass", }; @@ -223,8 +230,6 @@ const validDefinedModelOnNaturalEvidence: ValidatorFixture = { relations: [refines(specId("spec:orders.order-management"))], }), ], - packs: [], - anchors: [], }, expect: "pass", }; @@ -252,15 +257,102 @@ const validDefinedDecisionOnNaturalEvidence: ValidatorFixture = { relations: [refines(specId("spec:orders.create-order"))], }), ], - packs: [], - anchors: [], }, expect: "pass", }; +// The should-fail twins of the two natural-evidence passes above: the same evidence cells with the +// evidence absent — a floor that stops reading model.terms or decision.decision regresses here, +// never silently. The section's presence is not the evidence (per-kind evidence table, MD-12). +const invalidScopedModelWithoutTerms: ValidatorFixture = { + name: "invalid-scoped-model-without-terms", + model: { + specs: [ + spec({ + id: specId("spec:orders.order-management"), + title: "Order management", + kind: "behavior", + altitude: "epic", + readiness: "idea", + intent: { outcome: "Own the order lifecycle for checkout." }, + }), + spec({ + id: specId("spec:orders.order-model"), + title: "Order-management domain vocabulary", + kind: "model", + altitude: "story", + readiness: "scoped", + intent: { outcome: "Define the core order terms." }, + // A model's evidence is its terms: the bare section carries none, so scoped's + // evidence-present clause fails. + model: {}, + relations: [refines(specId("spec:orders.order-management"))], + }), + ], + }, + expect: { validatorId: "honesty/readiness-floor", relatedId: "kind-evidence-present" }, +}; + +const invalidScopedDecisionWithoutSection: ValidatorFixture = { + name: "invalid-scoped-decision-without-section", + model: { + specs: [ + spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "Turn a valid cart into an order." }, + }), + spec({ + id: specId("spec:decisions.order-lifecycle"), + title: "Order lifecycle keeps validation before creation", + kind: "decision", + altitude: "feature", + readiness: "scoped", + intent: { outcome: "Decide when an order may be created." }, + // No decision section at all: a decision-kind spec's natural evidence is missing outright. + relations: [refines(specId("spec:orders.create-order"))], + }), + ], + }, + expect: { validatorId: "honesty/readiness-floor", relatedId: "kind-evidence-present" }, +}; + +const invalidDefinedDecisionWithoutWrittenChoice: ValidatorFixture = { + name: "invalid-defined-decision-without-written-choice", + model: { + specs: [ + spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "Turn a valid cart into an order." }, + }), + spec({ + id: specId("spec:decisions.order-lifecycle"), + title: "Order lifecycle keeps validation before creation", + kind: "decision", + altitude: "feature", + readiness: "defined", + intent: { outcome: "Decide when an order may be created." }, + // Context alone clears scoped (the section is present — context may precede the choice) + // but not defined: evidence-complete requires decision.decision — the chosen option — + // written. + decision: { context: "Carts arrive at checkout both validated and unvalidated." }, + relations: [refines(specId("spec:orders.create-order"))], + }), + ], + }, + expect: { validatorId: "honesty/readiness-floor", relatedId: "kind-evidence-complete" }, +}; + // The runtime twin of the compile-time bypass fixture (MD-16): excess-property checking fires only // on fresh literals, so a section assembled through an intermediate variable smuggles a delivery -// fact past tsc — the authoring-shape honesty check is what catches it. +// fact past tsc — the authoring-shape honesty check over the graph is what catches it. const smuggledBehaviorSection = { rules: ["Only valid carts become orders."], "has-verifier": true, @@ -280,12 +372,40 @@ const invalidHandAuthoredDeliveryFactInSection: ValidatorFixture = { behavior: smuggledBehaviorSection, }), ], - packs: [], - anchors: [], }, expect: { validatorId: "honesty/authoring-shape", relatedId: "has-verifier" }, }; +// The array-carrier half of the same scan: an array section's carriers are its entries, so a +// delivery fact smuggled into a constraints[] entry is found at the entry's path — pinned via +// `path`, so the scan's array branch cannot regress behind the record-carrier pin above. +const smuggledConstraintEntry = { + statement: "Create-order should respond within the checkout budget.", + implemented: true, +}; + +const invalidHandAuthoredDeliveryFactInArrayEntry: ValidatorFixture = { + name: "invalid-hand-authored-delivery-fact-in-array-entry", + model: { + specs: [ + spec({ + id: specId("spec:orders.order-latency-constraint"), + title: "Create-order latency budget", + kind: "constraint", + altitude: "story", + readiness: "idea", + intent: { outcome: "Keep create-order fast enough for interactive checkout." }, + constraints: [smuggledConstraintEntry], + }), + ], + }, + expect: { + validatorId: "honesty/authoring-shape", + relatedId: "implemented", + path: "constraints[0].implemented", + }, +}; + // MD-16's promoted-evidence bound: an empty stub child is not a promotion — promotion moves content // out (MD-10), so a child carrying no evidence of its own never clears the parent's floor. const invalidDefinedBehaviorWithEmptyPromotedChild: ValidatorFixture = { @@ -321,8 +441,6 @@ const invalidDefinedBehaviorWithEmptyPromotedChild: ValidatorFixture = { relations: [refines(specId("spec:orders.create-order"))], }), ], - packs: [], - anchors: [], }, expect: { validatorId: "honesty/readiness-floor", relatedId: "kind-evidence-present" }, }; @@ -352,8 +470,6 @@ const invalidScopedBehaviorWithNonConstraintConstrainedBy: ValidatorFixture = { relations: [constrainedBy(specId("spec:orders.order-management"))], }), ], - packs: [], - anchors: [], }, expect: { validatorId: "honesty/readiness-floor", relatedId: "kind-evidence-present" }, }; @@ -367,7 +483,11 @@ export const activeValidatorFixtures: readonly ValidatorFixture[] = [ validDefinedWithNonBlockingQuestion, validDefinedModelOnNaturalEvidence, validDefinedDecisionOnNaturalEvidence, + invalidScopedModelWithoutTerms, + invalidScopedDecisionWithoutSection, + invalidDefinedDecisionWithoutWrittenChoice, invalidHandAuthoredDeliveryFactInSection, + invalidHandAuthoredDeliveryFactInArrayEntry, invalidDefinedBehaviorWithEmptyPromotedChild, invalidScopedBehaviorWithNonConstraintConstrainedBy, ]; diff --git a/test/graph-schema.test.ts b/test/graph-schema.test.ts index e5950a5..b5668b8 100644 --- a/test/graph-schema.test.ts +++ b/test/graph-schema.test.ts @@ -9,8 +9,8 @@ import { } from "../src/index.js"; describe("graph schema", () => { - it("exports the inert graph schema contracts", () => { - expect(schemaVersion).toBe("0.1.0"); + it("exports the graph schema contracts", () => { + expect(schemaVersion).toBe("0.3.0"); expect(graphNodeTypes).toEqual(["Primitive", "Pack", "Anchor", "CodeNode"]); expect(deliveryFactNames).toEqual(["implemented", "has-verifier", "observed"]); expect(derivedEdgeTypes).toEqual(["belongsTo", "satisfies"]); diff --git a/test/graph-schema.typecheck.ts b/test/graph-schema.typecheck.ts index 7a2de61..91f2f51 100644 --- a/test/graph-schema.typecheck.ts +++ b/test/graph-schema.typecheck.ts @@ -1,4 +1,6 @@ import type { + AnchorNode, + CodeNode, DeliveryFactName, GraphEdge, GraphEdgeType, @@ -13,11 +15,32 @@ const primitiveNode = { specKind: "behavior", altitude: "feature", readiness: "ready", + title: "Customer creates an order", + file: "specs/orders/create-order.sdp.ts", + sections: { intent: { outcome: "Turn a valid cart into an order." } }, deliveryFacts: ["implemented", "has-verifier"] as const satisfies readonly DeliveryFactName[], } satisfies PrimitiveNode; const graphNode = primitiveNode satisfies GraphNode; +// Binding nodes carry their binding location (file + line) — what a Design Review links to (R2). +const codeNode = { + id: "impl:orders.create-order-use-case", + nodeType: "CodeNode", + claim: "anchored", + label: "createOrderFromCart", + file: "src/orders/create-order.use-case.ts", + line: 23, +} satisfies CodeNode satisfies GraphNode; + +const anchorNode = { + id: "test:orders.create-order.valid-cart", + nodeType: "Anchor", + claim: "anchored", + file: "test/orders/create-order.valid-cart.test.ts", + line: 3, +} satisfies AnchorNode satisfies GraphNode; + const graphEdge = { from: "impl:orders.create-order-use-case", type: "satisfies" satisfies GraphEdgeType, @@ -26,4 +49,6 @@ const graphEdge = { } satisfies GraphEdge; void graphNode; +void codeNode; +void anchorNode; void graphEdge; diff --git a/test/helpers/extract-corpus.ts b/test/helpers/extract-corpus.ts new file mode 100644 index 0000000..1121b48 --- /dev/null +++ b/test/helpers/extract-corpus.ts @@ -0,0 +1,47 @@ +import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFUSING_SUFFIX = ".txt"; + +function copyDefusedTree(sourceDirectory: string, targetDirectory: string): void { + for (const entry of readdirSync(sourceDirectory, { withFileTypes: true })) { + if (entry.isDirectory()) { + const targetSubdirectory = join(targetDirectory, entry.name); + mkdirSync(targetSubdirectory); + copyDefusedTree(join(sourceDirectory, entry.name), targetSubdirectory); + continue; + } + + if (entry.name.endsWith(DEFUSING_SUFFIX)) { + copyFileSync( + join(sourceDirectory, entry.name), + join(targetDirectory, entry.name.slice(0, -DEFUSING_SUFFIX.length)), + ); + } + } +} + +/** + * Extraction corpora are committed defused — `*.sdp.ts.txt` for spec files, `*.ts.txt` for anchor + * source files — never under their real names: discovery sweeps every spec file *and* every + * source file under a build root, so a committed hard-error corpus would poison `sdp build` from + * any root above it (this repo's own root included), and the typecheck tsconfigs would sweep a + * deliberately type-incorrect `.ts` corpus. Materialization copies a corpus into a temp directory + * and strips the defusing suffix, so the extractor still reads genuine on-disk files (the corpus + * exercises the file-reading path, never in-memory objects). Directories materialize recursively, + * so a corpus can carry the directory layout a discovery rule is pinned against (a stray copy + * under a dot-directory). + */ +export function materializeExtractCorpus(name: string): string { + const source = fileURLToPath(new URL(`../fixtures/extract/${name}`, import.meta.url)); + const target = mkdtempSync(join(tmpdir(), `sdp-extract-${name}-`)); + copyDefusedTree(source, target); + + return target; +} + +export function removeMaterializedCorpus(root: string): void { + rmSync(root, { recursive: true, force: true }); +} diff --git a/test/helpers/fixture-graph.ts b/test/helpers/fixture-graph.ts new file mode 100644 index 0000000..a13fcf7 --- /dev/null +++ b/test/helpers/fixture-graph.ts @@ -0,0 +1,43 @@ +import { deriveGraph } from "../../src/extract/derive.js"; +import type { ReifiedAnchor } from "../../src/extract/anchors.js"; +import type { ReifiedPack, ReifiedSpec } from "../../src/extract/reify.js"; +import type { Anchor, GraphSchema, Pack, Spec } from "../../src/index.js"; + +export interface FixtureModel { + readonly specs?: readonly Spec[]; + readonly packs?: readonly Pack[]; + readonly anchors?: readonly Anchor[]; +} + +/** + * Derives a graph from DSL-built values through the real `deriveGraph` — fixtures keep the typed + * builders and the derivation logic stays single-sourced (no parallel test-side derivation to + * drift). Deliberately bypasses the extractor's duplicate-id exclusion: the graph-level backstop + * validators need carriers the extractor would have excluded, which is exactly the + * non-extractor-producer input class they exist for. + */ +export function deriveFixtureGraph(model: FixtureModel): GraphSchema { + const specs: ReifiedSpec[] = (model.specs ?? []).map((entry, position) => ({ + data: entry as unknown as Record, + id: entry.id, + file: "specs/fixture.sdp.ts", + line: position + 1, + })); + + const packs: ReifiedPack[] = (model.packs ?? []).map((entry, position) => ({ + data: entry as unknown as Record, + id: entry.id, + file: "specs/fixture.pack.sdp.ts", + line: position + 1, + })); + + const anchors: ReifiedAnchor[] = (model.anchors ?? []).map((entry, position) => ({ + data: entry as unknown as Record, + id: entry.id, + flavor: "satisfies" in entry ? "code" : "test", + file: "src/fixture.ts", + line: position + 1, + })); + + return deriveGraph(specs, packs, anchors); +} diff --git a/test/ids.test.ts b/test/ids.test.ts index ce260ed..da70c6b 100644 --- a/test/ids.test.ts +++ b/test/ids.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest"; import { anchorId, + codeAnchorId, formatId, - implAnchorId, packId, parseId, ref, @@ -44,15 +44,20 @@ describe("ids", () => { it("brands the required helper namespaces", () => { expect(packId("pack:checkout-v1")).toBe("pack:checkout-v1"); - expect(implAnchorId("impl:orders.create-order-use-case")).toBe( - "impl:orders.create-order-use-case", - ); expect(testAnchorId("test:orders.create-order.valid-cart")).toBe( "test:orders.create-order.valid-cart", ); expect(anchorId("api:orders.post")).toBe("api:orders.post"); }); + it("brands every implementation-flavored code namespace through the one codeAnchorId (MD-8)", () => { + expect(codeAnchorId("impl:orders.create-order-use-case")).toBe( + "impl:orders.create-order-use-case", + ); + expect(codeAnchorId("api:orders.post")).toBe("api:orders.post"); + expect(codeAnchorId("component:orders.domain")).toBe("component:orders.domain"); + }); + it.each(invalidIds)("rejects malformed IDs: %s", (value) => { expect(() => parseId(value)).toThrowError(value); }); @@ -60,8 +65,8 @@ describe("ids", () => { it("rejects wrong namespaces in helper branding", () => { expect(() => specId("pack:checkout-v1")).toThrowError('expected namespace "spec"'); expect(() => packId("spec:orders.create-order")).toThrowError('expected namespace "pack"'); - expect(() => implAnchorId("test:orders.create-order.valid-cart")).toThrowError( - 'expected namespace "impl"', + expect(() => codeAnchorId("test:orders.create-order.valid-cart")).toThrowError( + 'expected one of the namespaces "impl" · "api" · "component"', ); expect(() => testAnchorId("impl:orders.create-order-use-case")).toThrowError( 'expected namespace "test"', diff --git a/test/ids.typecheck.ts b/test/ids.typecheck.ts index d310002..99e26ad 100644 --- a/test/ids.typecheck.ts +++ b/test/ids.typecheck.ts @@ -1,11 +1,11 @@ import { type AnchorId, - type ImplAnchorId, + type CodeAnchorId, type PackId, type SpecId, type TestAnchorId, anchorId, - implAnchorId, + codeAnchorId, packId, ref, specId, @@ -15,8 +15,38 @@ import { const spec: SpecId = ref("spec:orders.create-order.valid-cart"); const alsoSpec: SpecId = specId("spec:orders.create-order"); const pack: PackId = packId("pack:checkout-v1"); -const impl: ImplAnchorId = implAnchorId("impl:orders.create-order-use-case"); +const impl: CodeAnchorId = codeAnchorId("impl:orders.create-order-use-case"); const test: TestAnchorId = testAnchorId("test:orders.create-order.valid-cart"); const anchor: AnchorId = anchorId("api:orders.post"); void [spec, alsoSpec, pack, impl, test, anchor]; + +// The id-brand discipline, pinned: one brand never assigns to another, a raw string carries no +// brand, and the generic anchorId result never narrows to a flavored anchor brand — only the +// validating constructors brand. + +// @ts-expect-error a PackId slot rejects a SpecId result +const wrongPack: PackId = specId("spec:orders.create-order"); + +// @ts-expect-error a raw string is unbranded — only specId() brands, after validating +const rawSpec: SpecId = "spec:orders.create-order"; + +// @ts-expect-error a TestAnchorId slot rejects a SpecId result +const wrongTest: TestAnchorId = specId("spec:orders.create-order.valid-cart"); + +// @ts-expect-error a CodeAnchorId slot rejects the generic anchorId result +const wrongImpl: CodeAnchorId = anchorId("api:orders.post"); + +// The two anchor flavors never cross-assign: the flavor is a second branded layer over the one +// `AnchorId` brand (see `ids.ts`), so a verifying binding and an implementation binding stay +// distinct types while both remain assignable to `AnchorId`. + +// @ts-expect-error a TestAnchorId slot rejects a CodeAnchorId result +const wrongFlavorTest: TestAnchorId = codeAnchorId("impl:orders.create-order-use-case"); + +// @ts-expect-error a CodeAnchorId slot rejects a TestAnchorId result +const wrongFlavorImpl: CodeAnchorId = testAnchorId("test:orders.create-order.valid-cart"); + +const widensToAnchor: AnchorId = codeAnchorId("impl:orders.create-order-use-case"); + +void [wrongPack, rawSpec, wrongTest, wrongImpl, wrongFlavorTest, wrongFlavorImpl, widensToAnchor]; diff --git a/test/reader.test.ts b/test/reader.test.ts new file mode 100644 index 0000000..a8ae4bf --- /dev/null +++ b/test/reader.test.ts @@ -0,0 +1,732 @@ +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { + codeAnchor, + codeAnchorId, + createReader, + extract, + graphValidatorIds, + pack, + packId, + refines, + spec, + specId, + specTest, + testAnchorId, + verifies, +} from "../src/index.js"; +import type { GraphSchema, Reader } from "../src/index.js"; +import { deriveFixtureGraph } from "./helpers/fixture-graph.js"; + +const exampleRoot = join(fileURLToPath(new URL("..", import.meta.url)), "examples", "checkout-v1"); + +/** The example graph, extracted once — the canonical consumer input (one graph, many readers). */ +const exampleGraph = extract({ root: exampleRoot }).graph; + +function exampleReader(): Reader { + return createReader(exampleGraph); +} + +describe("the reader — the thin typed loader behind the agent surface", () => { + describe("flat accessors", () => { + it("summarizes every spec with the decode done once: display label, recomputed facts, packs, derived readiness", () => { + const summaries = exampleReader().specs(); + + expect(summaries.map((summary) => summary.id)).toEqual([ + "spec:decisions.order-lifecycle", + "spec:orders.create-order", + "spec:orders.create-order.invalid-cart", + "spec:orders.create-order.valid-cart", + "spec:orders.order-inventory-rule", + "spec:orders.order-latency-constraint", + "spec:orders.order-management", + "spec:orders.order-model", + "spec:orders.order-total-rule", + ]); + + const createOrder = summaries.find((entry) => entry.id === "spec:orders.create-order"); + expect(createOrder).toMatchObject({ + title: "Customer creates an order", + specKind: "behavior", + kindDisplayLabel: "Use Case / Behavior", + altitude: "feature", + statedReadiness: "defined", + // Structurally clears the ready clauses too — derived above stated is ordinary + // information (the floor is never a quota); stating ready stays the human's call. + derivedReadiness: "ready", + deliveryFacts: ["implemented", "has-verifier"], + packs: ["pack:checkout-v1"], + }); + }); + + it("summarizes packs and exposes the full findings — the holes beside the assertions (JS-E2)", () => { + const reader = exampleReader(); + + expect(reader.packs()).toEqual([ + { + id: "pack:checkout-v1", + title: "Checkout v1", + framing: + "Let customers create orders from valid carts with honest authored traceability.", + file: "specs/checkout.pack.sdp.ts", + modelRefs: ["spec:orders.order-model"], + }, + ]); + + // The example's standing surfaced absence: the invalid-cart example's unenabled verifier. + expect(reader.findings()).toHaveLength(1); + expect(reader.findings()[0]).toMatchObject({ + validatorId: graphValidatorIds.verifiesLinkage, + severity: "warning", + subjectId: "spec:orders.create-order.invalid-cart", + }); + }); + }); + + describe("findByConcept — the grep→graph bridge from a string", () => { + it("matches ids first, then titles/labels, then section prose — deterministic, never fuzzy", () => { + const matches = exampleReader().findByConcept("create-order"); + + expect(matches[0]?.matchedIn).toContain("id"); + expect(matches.map((match) => match.id)).toContain("impl:orders.create-order-use-case"); + expect(matches.map((match) => match.id)).toContain("spec:orders.create-order"); + }); + + it("reaches the domain vocabulary: a model term key is content, not structure", () => { + const matches = exampleReader().findByConcept("inventorySnapshot"); + + expect(matches).toEqual([ + { + id: "spec:orders.order-model", + nodeType: "Primitive", + title: "Order-management domain vocabulary", + matchedIn: ["sections.model"], + }, + ]); + }); + + it("matches an anchor label, case-insensitively", () => { + const matches = exampleReader().findByConcept("post /ORDERS"); + + expect(matches).toEqual([ + { + id: "api:orders.post", + nodeType: "CodeNode", + matchedIn: ["label"], + }, + ]); + }); + + it("never matches section structure keys: a query naming a shape key finds only content", () => { + // Every example spec carries behavior.examples entries keyed given/when/then; the key + // itself must not match (only model.terms keys are content). + const matches = exampleReader().findByConcept("given"); + + expect(matches.filter((match) => match.matchedIn.includes("sections.behavior"))).toEqual([]); + }); + + it("returns nothing for an empty query", () => { + expect(exampleReader().findByConcept(" ")).toEqual([]); + }); + }); + + describe("byFile — the grep→graph bridge from a file", () => { + it("maps a spec file to the spec authored there", () => { + expect(exampleReader().byFile("specs/orders/create-order.sdp.ts")).toEqual({ + path: "specs/orders/create-order.sdp.ts", + nodes: [{ id: "spec:orders.create-order", nodeType: "Primitive" }], + specs: ["spec:orders.create-order"], + }); + }); + + it("maps a source file through its binding to the spec it satisfies (./-prefix normalized)", () => { + expect(exampleReader().byFile("./src/orders/create-order.use-case.ts")).toEqual({ + path: "src/orders/create-order.use-case.ts", + nodes: [{ id: "impl:orders.create-order-use-case", nodeType: "CodeNode", line: 23 }], + specs: ["spec:orders.create-order"], + }); + }); + + it("maps a test file through its anchor to the example it verifies", () => { + expect(exampleReader().byFile("test/orders/create-order.valid-cart.test.ts")).toEqual({ + path: "test/orders/create-order.valid-cart.test.ts", + nodes: [{ id: "test:orders.create-order.valid-cart", nodeType: "Anchor", line: 10 }], + specs: ["spec:orders.create-order.valid-cart"], + }); + }); + + it("answers honestly for a file the graph records nothing at", () => { + expect(exampleReader().byFile("src/orders/pricing.ts")).toEqual({ + path: "src/orders/pricing.ts", + nodes: [], + specs: [], + }); + }); + }); + + describe("blastRadius — file-level impact, coverage-unknown honest (`06` §2)", () => { + it("reaches the bound spec and its one-hop neighborhood with the connecting edges named", () => { + const radius = exampleReader().blastRadius(["src/orders/create-order.use-case.ts"]); + + expect(radius.impactedSpecs).toEqual([ + { + id: "spec:orders.create-order", + reasons: [ + { + file: "src/orders/create-order.use-case.ts", + throughBinding: { + id: "impl:orders.create-order-use-case", + edgeType: "satisfies", + claim: "anchored", + }, + }, + ], + }, + ]); + expect(radius.coverageUnknown).toEqual([]); + + const atRiskIds = radius.atRisk.map((item) => item.id); + // The parent, the children/verifiers, the pack, and the *other* binding are all one hop + // away; the changed file's own binding node is the change, not the risk. + expect(atRiskIds).toContain("spec:orders.order-management"); + expect(atRiskIds).toContain("spec:orders.create-order.valid-cart"); + expect(atRiskIds).toContain("pack:checkout-v1"); + expect(atRiskIds).toContain("api:orders.post"); + expect(atRiskIds).not.toContain("impl:orders.create-order-use-case"); + + const parent = radius.atRisk.find((item) => item.id === "spec:orders.order-management"); + expect(parent?.reasons).toEqual([ + { + from: "spec:orders.create-order", + edgeType: "refines", + to: "spec:orders.order-management", + claim: "declared", + }, + ]); + }); + + it("treats a changed spec file as direct impact (no binding in the reason)", () => { + const radius = exampleReader().blastRadius(["specs/orders/create-order.sdp.ts"]); + + expect(radius.impactedSpecs).toEqual([ + { + id: "spec:orders.create-order", + reasons: [{ file: "specs/orders/create-order.sdp.ts" }], + }, + ]); + }); + + it("treats a changed pack manifest as pack impact with the members at risk via belongsTo", () => { + const radius = exampleReader().blastRadius(["specs/checkout.pack.sdp.ts"]); + + expect(radius.impactedPacks).toEqual([ + { id: "pack:checkout-v1", reasons: [{ file: "specs/checkout.pack.sdp.ts" }] }, + ]); + expect(radius.atRisk).toHaveLength(9); + expect(radius.atRisk[0]?.reasons[0]?.edgeType).toBe("belongsTo"); + }); + + it("surfaces an unanchored changed file as coverage-unknown, never silently dropped", () => { + const radius = exampleReader().blastRadius([ + "src/orders/pricing.ts", + "src/orders/create-order.use-case.ts", + ]); + + expect(radius.coverageUnknown).toEqual(["src/orders/pricing.ts"]); + expect(radius.impactedSpecs.map((item) => item.id)).toEqual(["spec:orders.create-order"]); + }); + + it("normalizes, dedupes, and sorts the changed-file list", () => { + const radius = exampleReader().blastRadius([ + "./specs/orders/create-order.sdp.ts", + "specs/orders/create-order.sdp.ts", + "src/orders/pricing.ts", + ]); + + expect(radius.changedFiles).toEqual([ + "specs/orders/create-order.sdp.ts", + "src/orders/pricing.ts", + ]); + }); + }); + + describe("specContext — the irreducible per-spec join", () => { + it("joins relations, bindings, recomputed facts, and the spec's findings in one answer", () => { + const context = exampleReader().specContext("spec:orders.create-order"); + + expect(context).toBeDefined(); + expect(context?.relationsOut).toEqual([ + { + type: "constrainedBy", + claim: "declared", + otherId: "spec:orders.order-latency-constraint", + resolved: true, + otherNodeType: "Primitive", + otherTitle: "Create-order latency stays within checkout budget", + }, + { + type: "decidedBy", + claim: "declared", + otherId: "spec:decisions.order-lifecycle", + resolved: true, + otherNodeType: "Primitive", + otherTitle: "Order lifecycle keeps validation before creation", + }, + { + type: "refines", + claim: "declared", + otherId: "spec:orders.order-management", + resolved: true, + otherNodeType: "Primitive", + otherTitle: "Order management", + }, + ]); + + expect(context?.implementations).toEqual([ + { + codeId: "api:orders.post", + claim: "anchored", + label: "POST /orders", + file: "src/orders/create-order.route.ts", + line: 6, + }, + { + codeId: "impl:orders.create-order-use-case", + claim: "anchored", + label: "createOrderFromCart", + file: "src/orders/create-order.use-case.ts", + line: 23, + }, + ]); + + // The enabled-status decode (MD-7): the valid-cart example is anchor-backed, the + // invalid-cart one is not — the claim taxonomy travels with the data, never collapsed. + expect(context?.verifiers).toEqual([ + { + verifierId: "spec:orders.create-order.invalid-cart", + via: "example", + claim: "declared", + enabled: false, + label: "Invalid cart is rejected", + file: "specs/orders/create-order-invalid-cart.sdp.ts", + }, + { + verifierId: "spec:orders.create-order.valid-cart", + via: "example", + claim: "declared", + enabled: true, + label: "Valid cart creates an order", + file: "specs/orders/create-order-valid-cart.sdp.ts", + }, + ]); + + expect(context?.floorFailures).toEqual([]); + // The unenabled-verifier warning names this spec as related — visible from here too. + expect(context?.findings.map((finding) => finding.validatorId)).toEqual([ + graphValidatorIds.verifiesLinkage, + ]); + }); + + it("decodes a test anchor as an enabled verifier binding with its source location", () => { + const context = exampleReader().specContext("spec:orders.create-order.valid-cart"); + + expect(context?.verifiers).toEqual([ + { + verifierId: "test:orders.create-order.valid-cart", + via: "test-anchor", + claim: "anchored", + enabled: true, + label: "valid cart verifies the create-order happy path", + file: "test/orders/create-order.valid-cart.test.ts", + line: 10, + }, + ]); + }); + + it("returns undefined for an id the graph does not hold", () => { + expect(exampleReader().specContext("spec:orders.refund-order")).toBeUndefined(); + }); + }); + + 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"); + + expect(context?.members).toHaveLength(9); + expect(context?.members.every((member) => member.resolved)).toBe(true); + + // Verifier gaps: every member without a verifier binding; the example has two covered + // specs (create-order and its valid-cart example) and no ready member, so no priority. + expect(context?.verifierGaps.map((gap) => gap.id)).toEqual([ + "spec:decisions.order-lifecycle", + "spec:orders.create-order.invalid-cart", + "spec:orders.order-inventory-rule", + "spec:orders.order-latency-constraint", + "spec:orders.order-management", + "spec:orders.order-model", + "spec:orders.order-total-rule", + ]); + expect(context?.verifierGaps.every((gap) => !gap.priority)).toBe(true); + }); + + it("flags a ready member without a verifier as the priority gap", () => { + const parent = spec({ + id: specId("spec:orders.order-management"), + title: "Order management", + kind: "behavior", + altitude: "epic", + readiness: "defined", + intent: { outcome: "Coordinate the slice." }, + behavior: { rules: ["The slice stays traceable."] }, + }); + const readyRule = spec({ + id: specId("spec:orders.order-total-rule"), + title: "Order total matches cart math", + kind: "rule", + altitude: "story", + readiness: "ready", + intent: { outcome: "Keep totals deterministic." }, + behavior: { rules: ["The order total is the sum of all line subtotals."] }, + relations: [refines(specId("spec:orders.order-management"))], + }); + const graph = deriveFixtureGraph({ + specs: [parent, readyRule], + packs: [ + pack({ + id: packId("pack:checkout-v1"), + title: "Checkout v1", + specs: [specId("spec:orders.order-management"), specId("spec:orders.order-total-rule")], + }), + ], + }); + + const gaps = createReader(graph).packContext("pack:checkout-v1")?.verifierGaps; + + expect(gaps).toEqual([ + { id: "spec:orders.order-management", statedReadiness: "defined", priority: false }, + { id: "spec:orders.order-total-rule", statedReadiness: "ready", priority: true }, + ]); + }); + + it("returns undefined for a non-pack id", () => { + expect(exampleReader().packContext("spec:orders.create-order")).toBeUndefined(); + }); + }); + + describe("honesty over foreign producers", () => { + it("exposes recomputed delivery facts, never stated ones — the divergence stays a finding", () => { + const graph = deriveFixtureGraph({ + specs: [ + spec({ + id: specId("spec:orders.order-total-rule"), + title: "Order total matches cart math", + kind: "rule", + altitude: "story", + readiness: "idea", + intent: { outcome: "Keep totals deterministic." }, + }), + ], + }); + const faked: GraphSchema = { + ...graph, + nodes: graph.nodes.map((node) => + node.nodeType === "Primitive" ? { ...node, deliveryFacts: ["has-verifier"] } : node, + ), + }; + + const reader = createReader(faked); + + expect(reader.specs()[0]?.deliveryFacts).toEqual([]); + expect( + reader + .findings() + .some((finding) => finding.validatorId === graphValidatorIds.deliveryFacts), + ).toBe(true); + }); + + it("never decodes an anchored verifies edge from a non-Anchor source as the enabling test binding", () => { + const graph = deriveFixtureGraph({ + specs: [ + spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "Turn a valid cart into an order." }, + }), + spec({ + id: specId("spec:orders.create-order.valid-cart"), + title: "Valid cart creates an order", + kind: "example", + altitude: "story", + readiness: "idea", + intent: { outcome: "Verify the happy path." }, + relations: [verifies(specId("spec:orders.create-order"))], + }), + ], + anchors: [ + codeAnchor({ + id: codeAnchorId("impl:orders.create-order-use-case"), + satisfies: specId("spec:orders.create-order"), + }), + ], + }); + // A foreign producer points an anchored verifies edge from the CodeNode at the example — + // off-contract (`03` §1: an anchored verifies edge resolves from an Anchor node only). + const foreign: GraphSchema = { + ...graph, + edges: [ + ...graph.edges, + { + from: "impl:orders.create-order-use-case", + type: "verifies", + to: "spec:orders.create-order.valid-cart", + claim: "anchored", + }, + ], + }; + + const context = createReader(foreign).specContext("spec:orders.create-order"); + + // The example's enabled decode stays false (the shared resolving-test-anchor rule), and + // the parent earns no has-verifier from it — reader and derived facts agree, fail closed. + expect(context?.verifiers).toEqual([ + expect.objectContaining({ + verifierId: "spec:orders.create-order.valid-cart", + via: "example", + enabled: false, + }), + ]); + expect(context?.deliveryFacts).toEqual(["implemented"]); + }); + + it("never decodes an off-contract verifies edge from an anchor-bound example as enabled", () => { + const graph = deriveFixtureGraph({ + specs: [ + spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "Turn a valid cart into an order." }, + }), + spec({ + id: specId("spec:orders.create-order.valid-cart"), + title: "Valid cart creates an order", + kind: "example", + altitude: "story", + readiness: "idea", + intent: { outcome: "Verify the happy path." }, + }), + ], + anchors: [ + specTest({ + id: testAnchorId("test:orders.create-order.valid-cart"), + verifies: specId("spec:orders.create-order.valid-cart"), + }), + ], + }); + // A foreign producer points an inferred verifies edge from the anchor-bound example at the + // parent — off-contract (`03` §1: an example's verifies edge confers the binding only as + // `declared`); the claim taxonomy is never collapsed into "an example, so enabled". + const foreign: GraphSchema = { + ...graph, + edges: [ + ...graph.edges, + { + from: "spec:orders.create-order.valid-cart", + type: "verifies", + to: "spec:orders.create-order", + claim: "inferred", + }, + ], + }; + + const context = createReader(foreign).specContext("spec:orders.create-order"); + + // The decode stays not-enabled even though a resolving test anchor binds the example, and + // the parent earns no has-verifier — reader and derived facts agree, fail closed. + expect(context?.verifiers).toEqual([ + expect.objectContaining({ + verifierId: "spec:orders.create-order.valid-cart", + via: "example", + claim: "inferred", + enabled: false, + }), + ]); + expect(context?.deliveryFacts).toEqual([]); + }); + + it("never confers has-verifier from an anchor-bound non-example verifier — the kind gate of the shared enabled-example rule", () => { + // Extractor-reachable, not foreign: a behavior-kind spec declares verifies and a test + // anchor binds it. The enabled-example rule's kind gate must hold on both conferral + // surfaces at once — decode and derived facts ride the one shared predicate. + const graph = deriveFixtureGraph({ + specs: [ + spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "Turn a valid cart into an order." }, + }), + spec({ + id: specId("spec:orders.create-order.smoke"), + title: "Smoke check", + kind: "behavior", + altitude: "story", + readiness: "idea", + intent: { outcome: "A non-example verifier confers nothing." }, + relations: [verifies(specId("spec:orders.create-order"))], + }), + ], + anchors: [ + specTest({ + id: testAnchorId("test:orders.create-order.smoke"), + verifies: specId("spec:orders.create-order.smoke"), + }), + ], + }); + + const context = createReader(graph).specContext("spec:orders.create-order"); + + expect(context?.verifiers).toEqual([ + expect.objectContaining({ + verifierId: "spec:orders.create-order.smoke", + via: "example", + claim: "declared", + enabled: false, + }), + ]); + expect(context?.deliveryFacts).toEqual([]); + }); + + it("keys the verifier by first carrier on a duplicate-id graph — decode and derived facts agree", () => { + const graph = deriveFixtureGraph({ + specs: [ + spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "Turn a valid cart into an order." }, + }), + spec({ + id: specId("spec:orders.create-order.valid-cart"), + title: "Valid cart creates an order", + kind: "example", + altitude: "story", + readiness: "idea", + intent: { outcome: "Verify the happy path." }, + relations: [verifies(specId("spec:orders.create-order"))], + }), + ], + anchors: [ + specTest({ + id: testAnchorId("test:orders.create-order.valid-cart"), + verifies: specId("spec:orders.create-order.valid-cart"), + }), + ], + }); + const anchorNode = graph.nodes.find((node) => node.nodeType === "Anchor"); + + if (anchorNode === undefined) { + throw new Error("the fixture graph must carry an Anchor node"); + } + + // A foreign producer re-carries the example's id on an Anchor node ordered first. The + // duplicate-ids check owns the ambiguity loudly; until it is fixed, both conferral + // surfaces key the same first carrier, so neither confers from the declared edge. + const foreign: GraphSchema = { + ...graph, + nodes: [{ ...anchorNode, id: "spec:orders.create-order.valid-cart" }, ...graph.nodes], + }; + + const reader = createReader(foreign); + const context = reader.specContext("spec:orders.create-order"); + + expect( + reader.findings().some((finding) => finding.validatorId === graphValidatorIds.duplicateIds), + ).toBe(true); + expect(context?.verifiers).toEqual([ + expect.objectContaining({ + verifierId: "spec:orders.create-order.valid-cart", + via: "test-anchor", + enabled: false, + }), + ]); + expect(context?.deliveryFacts).toEqual([]); + }); + + it("carries the claim through impact answers — machine-derived reach is marked, never promoted", () => { + const graph = deriveFixtureGraph({ + specs: [ + spec({ + id: specId("spec:orders.order-total-rule"), + title: "Order total matches cart math", + kind: "rule", + altitude: "story", + readiness: "idea", + intent: { outcome: "Keep totals deterministic." }, + }), + ], + anchors: [ + codeAnchor({ + id: codeAnchorId("impl:orders.totals"), + satisfies: specId("spec:orders.order-total-rule"), + }), + ], + }); + // A foreign producer adds an off-contract inferred edge; the reader decodes it as data — + // the claim-separation error owns the contract violation, and the impact answer carries + // the claim so advisory reach is never mistaken for declared certainty (JS-G1 AC5). + const foreign: GraphSchema = { + ...graph, + edges: [ + ...graph.edges, + { + from: "impl:orders.totals", + type: "satisfies", + to: "spec:orders.order-total-rule", + claim: "inferred", + }, + ], + }; + + const radius = createReader(foreign).blastRadius(["src/fixture.ts"]); + const reasons = radius.impactedSpecs[0]?.reasons ?? []; + + expect(reasons.map((reason) => reason.throughBinding?.claim)).toEqual([ + "anchored", + "inferred", + ]); + expect( + createReader(foreign) + .findings() + .some((finding) => finding.validatorId === graphValidatorIds.claimSeparation), + ).toBe(true); + }); + }); + + describe("purity and determinism", () => { + it("never mutates the graph and answers identically across fresh readers", () => { + const before = JSON.stringify(exampleGraph); + const first = createReader(exampleGraph); + const second = createReader(exampleGraph); + + expect(first.specs()).toEqual(second.specs()); + expect(first.blastRadius(["src/orders/create-order.use-case.ts"])).toEqual( + second.blastRadius(["src/orders/create-order.use-case.ts"]), + ); + expect(first.findByConcept("cart")).toEqual(second.findByConcept("cart")); + expect(JSON.stringify(exampleGraph)).toBe(before); + }); + }); +}); diff --git a/test/reader.typecheck.ts b/test/reader.typecheck.ts new file mode 100644 index 0000000..235fa79 --- /dev/null +++ b/test/reader.typecheck.ts @@ -0,0 +1,39 @@ +import { createReader, schemaVersion } from "../src/index.js"; +import type { + BlastRadius, + ConceptMatch, + FileEntry, + PackContext, + Reader, + SpecContext, + SpecSummary, +} from "../src/index.js"; + +const reader: Reader = createReader({ schemaVersion, nodes: [], edges: [] }); + +// The frozen surface returns plain, composable data — every accessor's shape is the contract +// (the schema is the discovery surface: a typed field is a usable capability). +const summaries: readonly SpecSummary[] = reader.specs(); +const matches: readonly ConceptMatch[] = reader.findByConcept("rate limiter"); +const fileEntry: FileEntry = reader.byFile("src/orders/create-order.use-case.ts"); +const radius: BlastRadius = reader.blastRadius(["src/orders/create-order.use-case.ts"]); +const specContext: SpecContext | undefined = reader.specContext("spec:orders.create-order"); +const packContext: PackContext | undefined = reader.packContext("pack:checkout-v1"); + +// Coverage honesty is part of the type, not an optional extra (`06` §2). +const unknown: readonly string[] = radius.coverageUnknown; + +// Stated and derived readiness stay two fields — the divergence is data, never resolved away. +const stated = summaries[0]?.statedReadiness; +const derived = summaries[0]?.derivedReadiness; + +void [matches, fileEntry, specContext, packContext, unknown, stated, derived]; + +// @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"; + +void aspirational; + +// @ts-expect-error the reader is read-only composition — the graph is not assignable. +reader.graph = { schemaVersion, nodes: [], edges: [] }; diff --git a/test/readiness.test.ts b/test/readiness.test.ts index 7af0070..b04aa66 100644 --- a/test/readiness.test.ts +++ b/test/readiness.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { SPEC_KINDS, + buildGraphIndex, + deriveReadiness, evaluateReadinessFloor, kindEvidence, readinessFloors, @@ -11,10 +13,39 @@ import { validationSeverities, validatorFamilies, } from "../src/index.js"; -import type { AuthoredModel, Spec } from "../src/index.js"; +import type { GraphIndex, PrimitiveNode, ReadinessFloorFailure, Spec } from "../src/index.js"; +import { deriveFixtureGraph } from "./helpers/fixture-graph.js"; -function modelOf(...specs: readonly Spec[]): AuthoredModel { - return { specs, packs: [], anchors: [] }; +/** Indexes the graph derived from the given model and resolves the subject's Primitive node. */ +function indexedSubject( + subjectId: string, + specs: readonly Spec[], +): { node: PrimitiveNode; index: GraphIndex } { + const index = buildGraphIndex(deriveFixtureGraph({ specs })); + const node = index.primitivesById.get(subjectId); + + if (node === undefined) { + throw new Error(`Fixture graph is missing the subject node "${subjectId}".`); + } + + return { node, index }; +} + +/** Evaluates the floor for one spec over the graph derived from the given model. */ +function floorFailuresFor( + subjectId: string, + ...specs: readonly Spec[] +): readonly ReadinessFloorFailure[] { + const { node, index } = indexedSubject(subjectId, specs); + + return evaluateReadinessFloor(node, index); +} + +/** Derives the structural rung for one spec over the graph derived from the given model. */ +function derivedReadinessFor(subjectId: string, ...specs: readonly Spec[]) { + const { node, index } = indexedSubject(subjectId, specs); + + return deriveReadiness(node, index); } describe("readiness and validation contracts", () => { @@ -52,8 +83,35 @@ describe("readiness and validation contracts", () => { ]); }); - it("marks the ready clauses graph-shaped — evaluated over the one graph, never the pre-graph harness (one validation path, MD-14)", () => { - expect(readinessFloors.ready.clauses.every((clause) => "evaluatedOver" in clause)).toBe(true); + it("evaluates every clause — the ready clauses included — over the one graph (one validation path, MD-14)", () => { + const subject = spec({ + id: specId("spec:orders.order-total-rule"), + title: "Order total matches cart math", + kind: "rule", + altitude: "story", + readiness: "ready", + intent: { outcome: "Keep totals deterministic." }, + behavior: { rules: ["The order total is the sum of all line subtotals."] }, + relations: [refines(specId("spec:orders.order-management"))], + }); + const target = spec({ + id: specId("spec:orders.order-management"), + title: "Order management", + kind: "behavior", + altitude: "epic", + readiness: "defined", + intent: { outcome: "Own the order lifecycle for checkout." }, + behavior: { rules: ["Order management keeps the slice traceable."] }, + }); + + // With the refines target in the graph, every clause through ready holds. + expect(floorFailuresFor(subject.id, subject, target)).toEqual([]); + + // The identical spec over a graph missing the target flips the graph-shaped ready clause — + // the clause reads the one graph, not the spec value alone. + expect(floorFailuresFor(subject.id, subject).map((failure) => failure.clauseId)).toEqual([ + "all-relations-resolve", + ]); }); it("covers every kind in the evidence table; workflow and contract ride the behavior row (MD-12)", () => { @@ -85,11 +143,12 @@ describe("readiness and validation contracts", () => { relations: [refines(specId("spec:orders.create-order"))], }); - expect(evaluateReadinessFloor(parent, modelOf(parent, promotedRule))).toEqual([]); + expect(floorFailuresFor(parent.id, parent, promotedRule)).toEqual([]); - expect( - evaluateReadinessFloor(parent, modelOf(parent)).map((failure) => failure.clauseId), - ).toEqual(["kind-evidence-present", "kind-evidence-complete"]); + expect(floorFailuresFor(parent.id, parent).map((failure) => failure.clauseId)).toEqual([ + "kind-evidence-present", + "kind-evidence-complete", + ]); // An empty stub child is not a promotion (MD-16): promotion moves content out (MD-10), so a // rule child with no statement of its own contributes no evidence. @@ -103,7 +162,7 @@ describe("readiness and validation contracts", () => { }); expect( - evaluateReadinessFloor(parent, modelOf(parent, stubRule)).map((failure) => failure.clauseId), + floorFailuresFor(parent.id, parent, stubRule).map((failure) => failure.clauseId), ).toEqual(["kind-evidence-present", "kind-evidence-complete"]); }); @@ -121,12 +180,12 @@ describe("readiness and validation contracts", () => { }); const scoped = constraintAt("scoped"); - expect(evaluateReadinessFloor(scoped, modelOf(scoped))).toEqual([]); + expect(floorFailuresFor(scoped.id, scoped)).toEqual([]); const defined = constraintAt("defined"); - expect( - evaluateReadinessFloor(defined, modelOf(defined)).map((failure) => failure.clauseId), - ).toEqual(["kind-evidence-complete"]); + expect(floorFailuresFor(defined.id, defined).map((failure) => failure.clauseId)).toEqual([ + "kind-evidence-complete", + ]); }); it("requires a structured GWT entry for a defined example; prose clears scoped only (MD-10)", () => { @@ -143,9 +202,9 @@ describe("readiness and validation contracts", () => { }); const prose = exampleWith(["Valid cart becomes an order with the computed total."]); - expect( - evaluateReadinessFloor(prose, modelOf(prose)).map((failure) => failure.clauseId), - ).toEqual(["kind-evidence-complete"]); + expect(floorFailuresFor(prose.id, prose).map((failure) => failure.clauseId)).toEqual([ + "kind-evidence-complete", + ]); const structured = exampleWith([ { @@ -154,6 +213,79 @@ describe("readiness and validation contracts", () => { then: ["An order is created."], }, ]); - expect(evaluateReadinessFloor(structured, modelOf(structured))).toEqual([]); + expect(floorFailuresFor(structured.id, structured)).toEqual([]); + }); + + describe("derived readiness (the stated-vs-derived split, `05` §3)", () => { + const parent = spec({ + id: specId("spec:orders.order-management"), + title: "Order management", + kind: "behavior", + altitude: "epic", + readiness: "defined", + intent: { outcome: "Coordinate the order-management slice." }, + behavior: { rules: ["Order management keeps the slice traceable."] }, + }); + + /** A rule spec whose only relation resolves to the parent above. */ + const ruleAt = (readiness: Spec["readiness"], overrides?: Partial): Spec => + spec({ + id: specId("spec:orders.order-total-rule"), + title: "Order total matches cart math", + kind: "rule", + altitude: "story", + readiness, + intent: { outcome: "Keep totals deterministic." }, + behavior: { rules: ["The order total is the sum of all line subtotals."] }, + relations: [refines(specId("spec:orders.order-management"))], + ...overrides, + }); + + it("derives the highest cumulatively-cleared rung, independent of the stated one", () => { + // States idea but structurally clears every rung through ready — derived above stated is + // ordinary information, never a finding (the floor is a floor, not a quota). + expect(derivedReadinessFor("spec:orders.order-total-rule", ruleAt("idea"), parent)).toBe( + "ready", + ); + }); + + it("derives below the stated rung exactly where the floor check fails (the divergence)", () => { + const padded = ruleAt("ready", { + intent: { + outcome: "Keep totals deterministic.", + openQuestions: [{ question: "Do bundle discounts apply per line?", blocking: true }], + }, + }); + + // The blocking open question caps the derived rung at scoped; the stated ready also fails + // the floor check — the same table answers both readings (MD-13). + expect(derivedReadinessFor(padded.id, padded, parent)).toBe("scoped"); + expect( + floorFailuresFor(padded.id, padded, parent).map((failure) => failure.clauseId), + ).toEqual(["no-blocking-open-questions"]); + }); + + it("derives undefined when even the idea clauses fail", () => { + const bare = spec({ + id: specId("spec:orders.order-total-rule"), + title: "Order total matches cart math", + kind: "rule", + altitude: "story", + readiness: "idea", + // No intent.outcome and no parent relation: the idea floor itself is unmet. + }); + + expect(derivedReadinessFor(bare.id, bare)).toBeUndefined(); + }); + + it("stays total over an unratified kind: no rung derives, the conformance error owns it", () => { + const { node, index } = indexedSubject("spec:orders.order-total-rule", [ + ruleAt("scoped"), + parent, + ]); + const foreign = { ...node, specKind: "saga" as PrimitiveNode["specKind"] }; + + expect(deriveReadiness(foreign, index)).toBeUndefined(); + }); }); }); diff --git a/test/readiness.typecheck.ts b/test/readiness.typecheck.ts index 45ff22f..e53af20 100644 --- a/test/readiness.typecheck.ts +++ b/test/readiness.typecheck.ts @@ -1,46 +1,37 @@ -import { - anchorImplementation, - authoredEdgeTypes, - implAnchorId, - pack, - packId, - ref, - spec, - specId, -} from "../src/index.js"; -import type { AuthoredModel, Finding, ValidationReport, Validator } from "../src/index.js"; +import { authoredEdgeTypes, schemaVersion } from "../src/index.js"; +import type { Finding, GraphEdge, GraphSchema, ValidationReport, Validator } from "../src/index.js"; -const model = { - specs: [ - spec({ - id: specId("spec:orders.create-order"), - title: "Create order", - kind: "behavior", +const graph = { + schemaVersion, + nodes: [ + { + id: "spec:orders.create-order", + nodeType: "Primitive", + claim: "declared", + specKind: "behavior", altitude: "feature", readiness: "idea", - }), - ], - packs: [ - pack({ - id: packId("pack:checkout-v1"), - title: "Checkout v1", - specs: [ref("spec:orders.create-order")], - }), + title: "Create order", + file: "specs/orders/create-order.sdp.ts", + sections: { intent: { outcome: "Turn a valid cart into an order." } }, + }, ], - anchors: [ - anchorImplementation({ - id: implAnchorId("impl:orders.create-order-use-case"), - satisfies: ref("spec:orders.create-order"), - }), + edges: [ + { + from: "spec:orders.create-order", + type: "refines", + to: "spec:orders.order-management", + claim: "declared", + }, ], -} satisfies AuthoredModel; +} satisfies GraphSchema; const finding = { validatorId: "honesty/readiness-floor", family: "honesty", severity: "error", message: "readiness floor is not satisfied", - subjectId: model.specs[0]?.id, + subjectId: graph.nodes[0]?.id, relatedId: authoredEdgeTypes[0], path: "readiness", } satisfies Finding; @@ -51,26 +42,26 @@ const report = { findings: [finding], } satisfies ValidationReport; -const validator: Validator = { +// The validator contract defaults to the one validation seam: input is the graph (MD-14). +const validator: Validator = { id: "honesty/readiness-floor", family: "honesty", - validate(input) { + validate(input: GraphSchema) { void input; return report; }, }; -void [model, finding, report, validator]; +void [graph, finding, report, validator]; -const invalidAuthoredModel: AuthoredModel = { - specs: [], - packs: [], - anchors: [], - // @ts-expect-error the pre-graph authored model is an in-memory DTO — it carries no source-file bookkeeping. - sourceFiles: [], +// @ts-expect-error every edge records its claim (P9 — the taxonomy is never collapsed). +const invalidEdge: GraphEdge = { + from: "spec:orders.create-order", + type: "refines", + to: "spec:orders.order-management", }; -void invalidAuthoredModel; +void invalidEdge; const invalidFinding: Finding = { validatorId: "honesty/readiness-floor", diff --git a/test/validators.test.ts b/test/validators.test.ts index 2776349..98d8078 100644 --- a/test/validators.test.ts +++ b/test/validators.test.ts @@ -1,43 +1,156 @@ import { describe, expect, it } from "vitest"; import { - anchorImplementation, + codeAnchor, + codeAnchorId, + constrainedBy, + decidedBy, dependsOn, - implAnchorId, + graphValidatorIds, pack, packId, ref, + refines, + schemaVersion, spec, specId, specTest, + supersedes, testAnchorId, - validateAuthoredModel, - validateDanglingReferences, - validateDuplicateIds, - validateReadinessFloors, + validateGraph, } from "../src/index.js"; +import type { Finding, GraphEdge, GraphNode, GraphSchema, PrimitiveNode } from "../src/index.js"; +import { deriveFixtureGraph } from "./helpers/fixture-graph.js"; -import type { AuthoredModel, Spec } from "../src/index.js"; +/** + * Synthetic graphs (hand-built `GraphSchema` values) are a deliberate input class here: the graph + * is the public validation seam, and several checks have teeth only for a producer other than + * this repo's extractor — which excludes duplicates and derives every edge beside its node. + */ +function syntheticGraph(nodes: readonly GraphNode[], edges: readonly GraphEdge[]): GraphSchema { + return { schemaVersion, nodes, edges }; +} -function createBehaviorSpec( - id: ReturnType, - readiness: Spec["readiness"], - overrides: Partial = {}, -): Spec { - return spec({ +function ideaPrimitive(id: string, outcome: string): PrimitiveNode { + return { id, - title: `Title for ${id}`, - kind: "behavior", + nodeType: "Primitive", + claim: "declared", + specKind: "behavior", altitude: "feature", - readiness, - ...overrides, - }); + readiness: "idea", + title: `Title for ${id}`, + file: "specs/synthetic.sdp.ts", + sections: { intent: { outcome } }, + }; } -describe("validators", () => { - it("reports duplicate authored ids with the exact duplicated id", () => { +describe("graph validators", () => { + it("reports referential integrity across relations, pack members, modelRefs, and bindings", () => { + const existingSpec = spec({ + id: specId("spec:orders.order-management"), + title: "Order management", + kind: "behavior", + altitude: "epic", + readiness: "idea", + intent: { outcome: "Define order management." }, + }); + const graph = deriveFixtureGraph({ + specs: [ + spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "scoped", + intent: { outcome: "Turn a cart into an order." }, + behavior: { examples: ["valid cart"] }, + relations: [dependsOn(ref("spec:orders.missing-target"))], + }), + existingSpec, + ], + packs: [ + pack({ + id: packId("pack:checkout-v1"), + title: "Checkout v1", + specs: [ref(existingSpec.id), ref("spec:orders.missing-pack-member")], + modelRefs: [ref("spec:checkout.missing-glossary")], + }), + ], + anchors: [ + codeAnchor({ + id: codeAnchorId("impl:orders.create-order-use-case"), + satisfies: ref("spec:orders.missing-anchor-target"), + }), + specTest({ + id: testAnchorId("test:orders.create-order.valid-cart"), + verifies: ref("spec:orders.missing-test-target"), + }), + ], + }); + + const findings = validateGraph(graph).findings; + expect( + findings.every((finding) => finding.validatorId === graphValidatorIds.referentialIntegrity), + ).toBe(true); + expect(findings.every((finding) => finding.severity === "error")).toBe(true); + + const pairs = findings + .map((finding) => `${finding.subjectId ?? ""} -> ${finding.relatedId ?? ""}`) + .sort(); + expect(pairs).toEqual([ + "impl:orders.create-order-use-case -> spec:orders.missing-anchor-target", + "pack:checkout-v1 -> spec:checkout.missing-glossary", + "spec:orders.create-order -> spec:orders.missing-target", + "spec:orders.missing-pack-member -> pack:checkout-v1", + "test:orders.create-order.valid-cart -> spec:orders.missing-test-target", + ]); + }); + + it('offers a "did you mean" suggestion only when one nearest id is unambiguous (L2)', () => { + const withUniqueNearMiss = syntheticGraph( + [ideaPrimitive("spec:orders.create-order", "Turn a valid cart into an order.")], + [ + { + from: "spec:orders.create-order", + type: "dependsOn", + to: "spec:orders.create-ordr", + claim: "declared", + }, + ], + ); + + const suggested = validateGraph(withUniqueNearMiss).findings.find( + (finding) => finding.validatorId === graphValidatorIds.referentialIntegrity, + ); + expect(suggested?.message).toContain('Did you mean "spec:orders.create-order"?'); + + const withTiedCandidates = syntheticGraph( + [ + ideaPrimitive("spec:orders.create-order-a", "First near miss."), + ideaPrimitive("spec:orders.create-order-b", "Second near miss."), + ], + [ + { + from: "spec:orders.create-order-a", + type: "dependsOn", + to: "spec:orders.create-order-x", + claim: "declared", + }, + ], + ); + + const tied = validateGraph(withTiedCandidates).findings.find( + (finding) => finding.validatorId === graphValidatorIds.referentialIntegrity, + ); + expect(tied).toBeDefined(); + // A tie yields no suggestion: picking a winner would auto-resolve ambiguity. + expect(tied?.message).not.toContain("Did you mean"); + }); + + it("reports duplicate node ids — the graph backstop behind the extractor's per-site errors", () => { const duplicateId = specId("spec:orders.create-order"); - const report = validateDuplicateIds({ + const graph = deriveFixtureGraph({ specs: [ spec({ id: duplicateId, @@ -45,6 +158,7 @@ describe("validators", () => { kind: "behavior", altitude: "feature", readiness: "idea", + intent: { outcome: "Turn a valid cart into an order." }, }), spec({ id: duplicateId, @@ -52,137 +166,268 @@ describe("validators", () => { kind: "behavior", altitude: "feature", readiness: "idea", + intent: { outcome: "Accidental second definition." }, }), ], - packs: [], - anchors: [], }); - expect(report.validatorId).toBe("conformance/duplicate-ids"); - expect(report.family).toBe("conformance"); - expect(report.findings).toHaveLength(1); - expect(report.findings[0]).toMatchObject({ - validatorId: "conformance/duplicate-ids", - family: "conformance", - severity: "error", - subjectId: duplicateId, - message: 'Duplicate authored id "spec:orders.create-order".', - }); + const duplicates = validateGraph(graph).findings.filter( + (finding) => finding.validatorId === graphValidatorIds.duplicateIds, + ); + expect(duplicates).toHaveLength(2); + expect( + duplicates.every( + (finding) => finding.severity === "error" && finding.subjectId === duplicateId, + ), + ).toBe(true); }); - it("reports dangling authored references across relations, packs, and anchors", () => { - const existingSpecId = specId("spec:orders.order-management"); - const relationTarget = specId("spec:orders.missing-target"); - const packSpecTarget = specId("spec:orders.missing-pack-member"); - const packModelTarget = specId("spec:checkout.missing-glossary"); - const anchorTarget = specId("spec:orders.missing-anchor-target"); - const testTarget = specId("spec:orders.missing-test-target"); + it("keeps the claim taxonomy uncollapsed: a declared satisfies edge violates the edge contract", () => { + const graph = syntheticGraph( + [ + { + id: "impl:orders.create-order-use-case", + nodeType: "CodeNode", + claim: "anchored", + file: "src/orders/create-order.use-case.ts", + line: 3, + }, + ideaPrimitive("spec:orders.create-order", "Turn a valid cart into an order."), + ], + [ + { + from: "impl:orders.create-order-use-case", + type: "satisfies", + to: "spec:orders.create-order", + claim: "declared", + }, + ], + ); - const report = validateDanglingReferences({ - specs: [ - createBehaviorSpec(specId("spec:orders.create-order"), "scoped", { - intent: { outcome: "turn a cart into an order" }, - behavior: { examples: ["valid cart"] }, - relations: [dependsOn(relationTarget)], - }), - createBehaviorSpec(existingSpecId, "idea", { - intent: { outcome: "define order management" }, - }), + const findings = validateGraph(graph).findings; + expect(findings).toHaveLength(1); + expect(findings[0]?.validatorId).toBe(graphValidatorIds.claimSeparation); + expect(findings[0]?.severity).toBe("error"); + expect(findings[0]?.message).toContain('a satisfies edge carries "anchored"'); + }); + + it("rejects a node claim its nodeType never carries", () => { + const graph = syntheticGraph( + [ + { + ...ideaPrimitive("spec:orders.create-order", "Turn a valid cart into an order."), + claim: "anchored", + }, ], - packs: [ - pack({ - id: packId("pack:checkout-v1"), - title: "Checkout v1", - specs: [ref(existingSpecId), packSpecTarget], - modelRefs: [packModelTarget], - }), + [], + ); + + const claims = validateGraph(graph).findings.filter( + (finding) => finding.validatorId === graphValidatorIds.claimSeparation, + ); + expect(claims).toHaveLength(1); + expect(claims[0]?.message).toContain('Primitive nodes carry "declared"'); + }); + + it("rejects wrong-kind endpoints on the kind-typed relations — constrainedBy, decidedBy, supersedes", () => { + const orderManagement = spec({ + id: specId("spec:orders.order-management"), + title: "Order management", + kind: "behavior", + altitude: "epic", + readiness: "idea", + intent: { outcome: "Define order management." }, + }); + const orderLifecycle = spec({ + id: specId("spec:decisions.order-lifecycle"), + title: "Order lifecycle", + kind: "decision", + altitude: "story", + readiness: "idea", + intent: { outcome: "Settle the order lifecycle." }, + // A decision superseding a rule-kind spec: the to-endpoint row fails. + relations: [supersedes(specId("spec:orders.order-total-rule"))], + }); + const orderTotalRule = spec({ + id: specId("spec:orders.order-total-rule"), + title: "Order total rule", + kind: "rule", + altitude: "story", + readiness: "idea", + intent: { outcome: "Keep order totals consistent." }, + // A rule superseding a decision: the from-endpoint row fails. + relations: [supersedes(orderLifecycle.id)], + }); + const createOrder = spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "Turn a valid cart into an order." }, + relations: [ + // A behavior-kind bound and a behavior-kind decider: both target rows fail. + constrainedBy(orderManagement.id), + decidedBy(orderManagement.id), ], - anchors: [ - anchorImplementation({ - id: implAnchorId("impl:orders.create-order-use-case"), - satisfies: anchorTarget, - }), - specTest({ - id: testAnchorId("test:orders.create-order.valid-cart"), - verifies: testTarget, - }), + }); + + const findings = validateGraph( + deriveFixtureGraph({ specs: [orderManagement, orderLifecycle, orderTotalRule, createOrder] }), + ).findings; + + expect(findings).toHaveLength(4); + expect( + findings.every( + (finding) => + finding.validatorId === graphValidatorIds.claimSeparation && finding.severity === "error", + ), + ).toBe(true); + + const messages = findings.map((finding) => finding.message); + expect(messages.some((m) => m.includes("(constrainedBy)") && m.includes("behavior-kind"))).toBe( + true, + ); + expect(messages.some((m) => m.includes("(decidedBy)") && m.includes("behavior-kind"))).toBe( + true, + ); + expect( + messages.some((m) => m.includes("(supersedes)") && m.includes("originates from a rule-kind")), + ).toBe(true); + expect( + messages.some((m) => m.includes("(supersedes)") && m.includes("targets a rule-kind")), + ).toBe(true); + }); + + it("accepts the valid kind-typed relation shapes — rule/constraint bounds, a decision decider, decision supersedes decision", () => { + const latencyConstraint = spec({ + id: specId("spec:orders.order-latency-constraint"), + title: "Order latency constraint", + kind: "constraint", + altitude: "story", + readiness: "idea", + intent: { outcome: "Bound order-creation latency." }, + }); + const orderTotalRule = spec({ + id: specId("spec:orders.order-total-rule"), + title: "Order total rule", + kind: "rule", + altitude: "story", + readiness: "idea", + intent: { outcome: "Keep order totals consistent." }, + }); + const orderLifecycle = spec({ + id: specId("spec:decisions.order-lifecycle"), + title: "Order lifecycle", + kind: "decision", + altitude: "story", + readiness: "idea", + intent: { outcome: "Settle the order lifecycle." }, + }); + const orderLifecycleV2 = spec({ + id: specId("spec:decisions.order-lifecycle-v2"), + title: "Order lifecycle v2", + kind: "decision", + altitude: "story", + readiness: "idea", + intent: { outcome: "Revise the order lifecycle." }, + relations: [supersedes(orderLifecycle.id)], + }); + const createOrder = spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "Turn a valid cart into an order." }, + relations: [ + constrainedBy(latencyConstraint.id), + constrainedBy(orderTotalRule.id), + decidedBy(orderLifecycleV2.id), ], }); - expect(report.validatorId).toBe("conformance/dangling-references"); - expect(report.family).toBe("conformance"); - expect(report.findings).toEqual([ - { - validatorId: "conformance/dangling-references", - family: "conformance", - severity: "error", - subjectId: "spec:orders.create-order", - relatedId: "spec:orders.missing-target", - path: "relations[0].target", - message: - 'Authored reference from "spec:orders.create-order" points to missing target "spec:orders.missing-target" at "relations[0].target".', - }, - { - validatorId: "conformance/dangling-references", - family: "conformance", - severity: "error", - subjectId: "pack:checkout-v1", - relatedId: "spec:orders.missing-pack-member", - path: "specs[1]", - message: - 'Authored reference from "pack:checkout-v1" points to missing target "spec:orders.missing-pack-member" at "specs[1]".', - }, - { - validatorId: "conformance/dangling-references", - family: "conformance", - severity: "error", - subjectId: "pack:checkout-v1", - relatedId: "spec:checkout.missing-glossary", - path: "modelRefs[0]", - message: - 'Authored reference from "pack:checkout-v1" points to missing target "spec:checkout.missing-glossary" at "modelRefs[0]".', - }, - { - validatorId: "conformance/dangling-references", - family: "conformance", - severity: "error", - subjectId: "impl:orders.create-order-use-case", - relatedId: "spec:orders.missing-anchor-target", - path: "satisfies", - message: - 'Authored reference from "impl:orders.create-order-use-case" points to missing target "spec:orders.missing-anchor-target" at "satisfies".', - }, - { - validatorId: "conformance/dangling-references", - family: "conformance", - severity: "error", - subjectId: "test:orders.create-order.valid-cart", - relatedId: "spec:orders.missing-test-target", - path: "verifies", - message: - 'Authored reference from "test:orders.create-order.valid-cart" points to missing target "spec:orders.missing-test-target" at "verifies".', + const graph = deriveFixtureGraph({ + specs: [latencyConstraint, orderTotalRule, orderLifecycle, orderLifecycleV2, createOrder], + }); + + expect(validateGraph(graph).findings).toEqual([]); + }); + + it("fails the anchors-resolve ready clause when a binding edge has no binding node behind it", () => { + const readyNode: GraphNode = { + id: "spec:orders.create-order", + nodeType: "Primitive", + claim: "declared", + specKind: "behavior", + altitude: "feature", + readiness: "ready", + title: "Create order", + file: "specs/synthetic.sdp.ts", + sections: { + intent: { outcome: "Turn a valid cart into an order." }, + behavior: { rules: ["Only valid carts become orders."] }, }, - ]); + }; + const graph = syntheticGraph( + [readyNode], + [ + { + from: "impl:orders.ghost-binding", + type: "satisfies", + to: "spec:orders.create-order", + claim: "anchored", + }, + ], + ); + + const findings = validateGraph(graph).findings; + // The ghost source is a conformance error, and the floor names the unearned ready: without a + // real binding node, `implemented` would not be derivable from a binding (MD-7). + expect( + findings.some((finding) => finding.validatorId === graphValidatorIds.referentialIntegrity), + ).toBe(true); + expect( + findings.some( + (finding) => + finding.validatorId === graphValidatorIds.readinessFloor && + finding.relatedId === "anchors-resolve", + ), + ).toBe(true); }); - it("reports a single-spec ready floor failure with the spec id and stated readiness", () => { - const report = validateReadinessFloors({ + it("reports a readiness-floor failure with the spec id, stated rung, and failing clause", () => { + const graph = deriveFixtureGraph({ specs: [ - createBehaviorSpec(specId("spec:orders.create-order"), "ready", { - intent: { outcome: "turn a valid cart into an order" }, - relations: [dependsOn(ref("spec:orders.order-management"))], - // An inline constraint clears the scoped evidence rung, but a defined behavior spec needs - // rules and/or examples — constraints alone no longer suffice (MD-12). + spec({ + id: specId("spec:orders.order-management"), + title: "Order management", + kind: "behavior", + altitude: "epic", + readiness: "defined", + intent: { outcome: "Define order management." }, + behavior: { rules: ["Orders stay traceable."] }, + relations: [dependsOn(specId("spec:orders.create-order"))], + }), + spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "ready", + intent: { outcome: "Turn a valid cart into an order." }, + relations: [refines(specId("spec:orders.order-management"))], + // An inline constraint clears the scoped evidence rung, but a defined behavior spec + // needs rules and/or examples — constraints alone no longer suffice (MD-12). constraints: [{ statement: "order creation stays fast", target: "p95 < 200ms" }], }), ], - packs: [], - anchors: [], }); - expect(report.validatorId).toBe("honesty/readiness-floor"); - expect(report.family).toBe("honesty"); - expect(report.findings).toEqual([ + const floorFindings = validateGraph(graph).findings.filter( + (finding) => finding.validatorId === graphValidatorIds.readinessFloor, + ); + expect(floorFindings).toEqual([ { validatorId: "honesty/readiness-floor", family: "honesty", @@ -190,69 +435,245 @@ describe("validators", () => { subjectId: "spec:orders.create-order", relatedId: "kind-evidence-complete", path: "readiness", + file: "specs/fixture.sdp.ts", message: 'Spec "spec:orders.create-order" states readiness "ready" but does not satisfy floor clause "kind-evidence-complete": The kind\'s natural evidence is complete (per-kind evidence table).', }, ]); }); - it("skips graph-shaped ready clauses pre-graph (target readiness and anchor resolution wait for the extractor)", () => { - const report = validateReadinessFloors({ - specs: [ - createBehaviorSpec(specId("spec:orders.create-order"), "ready", { - intent: { outcome: "turn a valid cart into an order" }, - behavior: { rules: ["persist the order"] }, - relations: [dependsOn(ref("spec:orders.undefined-dependency"))], - }), - ], - packs: [], - anchors: [ - anchorImplementation({ - id: implAnchorId("impl:orders.undefined-anchor"), - satisfies: ref("spec:orders.undefined-anchor-target"), - }), - ], - }); + it("surfaces the gap only while no verifier resolves — a derived fact silences it, a stated one never does", () => { + const readySpec: PrimitiveNode = { + ...ideaPrimitive("spec:orders.create-order", "Turn a valid cart into an order."), + readiness: "ready", + }; + const testAnchor: GraphNode = { + id: "test:orders.create-order.valid-cart", + nodeType: "Anchor", + claim: "anchored", + file: "test/create-order.valid-cart.test.ts", + line: 7, + }; + const anchoredVerifies: GraphEdge = { + from: testAnchor.id, + type: "verifies", + to: readySpec.id, + claim: "anchored", + }; - expect(report.findings).toEqual([]); + const findingsFor = (nodes: readonly GraphNode[], edges: readonly GraphEdge[]) => + validateGraph(syntheticGraph(nodes, edges)).findings; + const gapsOf = (findings: readonly Finding[]) => + findings.filter((finding) => finding.validatorId === graphValidatorIds.gaps); + const factsOf = (findings: readonly Finding[]) => + findings.filter((finding) => finding.validatorId === graphValidatorIds.deliveryFacts); + + // No verifier at all: the gap fires, informative only. + const unverified = findingsFor([readySpec], []); + expect(gapsOf(unverified)).toHaveLength(1); + expect(gapsOf(unverified)[0]?.severity).toBe("warning"); + + // A resolving test binding derives has-verifier (stated consistently): the gap is silenced. + const bound = findingsFor( + [{ ...readySpec, deliveryFacts: ["has-verifier"] }, testAnchor], + [anchoredVerifies], + ); + expect(gapsOf(bound)).toEqual([]); + expect(factsOf(bound)).toEqual([]); + + // A *stated* has-verifier no binding earns never silences the gap — the gap check reads the + // recomputed facts, and the disagreement is the delivery-facts check's own honesty error. + const faked = findingsFor([{ ...readySpec, deliveryFacts: ["has-verifier"] }], []); + expect(gapsOf(faked)).toHaveLength(1); + expect(factsOf(faked)).toHaveLength(1); + expect(factsOf(faked)[0]?.message).toContain("derived, never authored"); }); - it("returns a valid empty authored-model report without throwing", () => { - const report = validateAuthoredModel({ - specs: [], - packs: [], - anchors: [], + it("never lets an anchored verifies edge from a non-Anchor source enable an example — fail closed", () => { + const parent = ideaPrimitive("spec:orders.create-order", "Turn a valid cart into an order."); + const example: PrimitiveNode = { + ...ideaPrimitive("spec:orders.create-order.valid-cart", "Verify the happy path."), + specKind: "example", + altitude: "story", + }; + const impostor: GraphNode = { + id: "impl:orders.create-order-use-case", + nodeType: "CodeNode", + claim: "anchored", + file: "src/orders/create-order.use-case.ts", + }; + + const findings = validateGraph( + syntheticGraph( + [parent, example, impostor], + [ + { from: example.id, type: "verifies", to: parent.id, claim: "declared" }, + // Off-contract: an anchored verifies edge resolves from an Anchor node only (`03` §1). + { from: impostor.id, type: "verifies", to: example.id, claim: "anchored" }, + ], + ), + ).findings; + + // The off-contract edge is the claim-separation check's own finding... + expect( + findings.filter((finding) => finding.validatorId === graphValidatorIds.claimSeparation), + ).toHaveLength(1); + + // ...and it never stands in for the test binding: the example stays un-enabled, so the + // incomplete spec↔test trace is still named loudly (the shared resolving-test-anchor rule). + const linkage = findings.filter( + (finding) => finding.validatorId === graphValidatorIds.verifiesLinkage, + ); + expect(linkage).toHaveLength(1); + expect(linkage[0]).toMatchObject({ + subjectId: example.id, + relatedId: parent.id, + severity: "warning", }); + }); + + it("rejects stated delivery facts the graph does not earn — derived, never authored", () => { + const node = { + ...ideaPrimitive("spec:orders.create-order", "Turn a valid cart into an order."), + deliveryFacts: ["implemented", "observed", "done"], + } as unknown as PrimitiveNode; + + const findings = validateGraph(syntheticGraph([node], [])).findings.filter( + (finding) => finding.validatorId === graphValidatorIds.deliveryFacts, + ); - expect(report.validatorId).toBe("authored-model"); + expect(findings.map((finding) => finding.relatedId).sort()).toEqual([ + "done", + "implemented", + "observed", + ]); + expect(findings.every((finding) => finding.severity === "error")).toBe(true); + expect(findings.find((finding) => finding.relatedId === "done")?.message).toContain( + "unknown delivery fact", + ); + expect(findings.find((finding) => finding.relatedId === "observed")?.message).toContain( + "aspirational", + ); + }); + + it("rejects an omitted delivery fact the graph's resolving bindings derive", () => { + const specNode = ideaPrimitive("spec:orders.create-order", "Turn a valid cart into an order."); + const codeNode: GraphNode = { + id: "impl:orders.create-order-use-case", + nodeType: "CodeNode", + claim: "anchored", + file: "src/orders/create-order.use-case.ts", + line: 3, + }; + + const findings = validateGraph( + syntheticGraph( + [specNode, codeNode], + [{ from: codeNode.id, type: "satisfies", to: specNode.id, claim: "anchored" }], + ), + ).findings.filter((finding) => finding.validatorId === graphValidatorIds.deliveryFacts); + + expect(findings).toHaveLength(1); + expect(findings[0]?.relatedId).toBe("implemented"); + expect(findings[0]?.message).toContain("omits the delivery fact"); + }); + + it("fails closed on unratified descriptor values — a conformance error, never a crash or a silent floor skip", () => { + const bogusKind = { + ...ideaPrimitive("spec:orders.create-order", "Turn a valid cart into an order."), + specKind: "saga", + readiness: "scoped", + } as unknown as PrimitiveNode; + const bogusReadiness = { + ...ideaPrimitive("spec:orders.order-model", "Define the order terms."), + readiness: "later", + } as unknown as PrimitiveNode; + const bogusAltitude = { + ...ideaPrimitive("spec:orders.order-management", "Define order management."), + altitude: "initiative", + } as unknown as PrimitiveNode; + + // Evaluating the scoped floor over specKind "saga" used to dereference the evidence table; + // the seam must report, never throw. + const findings = validateGraph( + syntheticGraph([bogusKind, bogusReadiness, bogusAltitude], []), + ).findings; + + const descriptorErrors = findings.filter( + (finding) => + finding.validatorId === graphValidatorIds.claimSeparation && + finding.message.includes("outside the ratified descriptor values"), + ); + expect(descriptorErrors.map((finding) => finding.path).sort()).toEqual([ + "altitude", + "readiness", + "specKind", + ]); + expect(descriptorErrors.every((finding) => finding.severity === "error")).toBe(true); + + // Fail closed: no floor evaluation over an unratified kind or readiness — the conformance + // error owns the finding, instead of a crash (bogus kind) or a silent skip (bogus readiness). + expect( + findings.filter( + (finding) => + finding.validatorId === graphValidatorIds.readinessFloor && + (finding.subjectId === bogusKind.id || finding.subjectId === bogusReadiness.id), + ), + ).toEqual([]); + }); + + it("returns a valid empty aggregate report without throwing", () => { + const report = validateGraph(syntheticGraph([], [])); + + expect(report.validatorId).toBe("graph"); // The aggregate spans both check families, so it carries no single family of its own (F3). expect(report.family).toBeUndefined(); expect(report.findings).toEqual([]); }); - it("composes the authored-layer validators for a valid non-empty model", () => { - const orderManagement = createBehaviorSpec(specId("spec:orders.order-management"), "idea", { - intent: { outcome: "define order management" }, + it("composes cleanly over a valid non-empty model with bindings, a pack, and a model-kind modelRef", () => { + const orderManagement = spec({ + id: specId("spec:orders.order-management"), + title: "Order management", + kind: "behavior", + altitude: "epic", + readiness: "idea", + intent: { outcome: "Define order management." }, + }); + const orderModel = spec({ + id: specId("spec:orders.order-model"), + title: "Order-management domain vocabulary", + kind: "model", + altitude: "story", + readiness: "defined", + intent: { outcome: "Define the core order terms." }, + model: { terms: { cart: "A customer-selected set of line items." } }, + relations: [refines(orderManagement.id)], }); - const createOrder = createBehaviorSpec(specId("spec:orders.create-order"), "ready", { - intent: { outcome: "turn a valid cart into an order" }, - behavior: { rules: ["persist the order"] }, - relations: [dependsOn(orderManagement.id)], + const createOrder = spec({ + id: specId("spec:orders.create-order"), + title: "Create order", + kind: "behavior", + altitude: "feature", + readiness: "defined", + intent: { outcome: "Turn a valid cart into an order." }, + behavior: { rules: ["Only valid carts become orders."] }, + relations: [refines(orderManagement.id)], }); - const model: AuthoredModel = { - specs: [orderManagement, createOrder], + const graph = deriveFixtureGraph({ + specs: [orderManagement, orderModel, createOrder], packs: [ pack({ id: packId("pack:checkout-v1"), title: "Checkout v1", - specs: [orderManagement.id, createOrder.id], - modelRefs: [orderManagement.id], + specs: [orderManagement.id, orderModel.id, createOrder.id], + modelRefs: [orderModel.id], }), ], anchors: [ - anchorImplementation({ - id: implAnchorId("impl:orders.create-order-use-case"), + codeAnchor({ + id: codeAnchorId("impl:orders.create-order-use-case"), satisfies: createOrder.id, }), specTest({ @@ -260,8 +681,8 @@ describe("validators", () => { verifies: createOrder.id, }), ], - }; + }); - expect(validateAuthoredModel(model).findings).toEqual([]); + expect(validateGraph(graph).findings).toEqual([]); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 80ca24e..2bb383b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,8 @@ export default defineConfig({ test: { environment: "node", globals: true, - include: ["test/**/*.test.ts"], + // The example's tests run too: the tracer-bullet verifier anchor must sit beside a real, + // executing runner test (`04` §2), not stand alone as a binding-only file. + include: ["test/**/*.test.ts", "examples/**/*.test.ts"], }, });