diff --git a/.agents/skills/sdp-agent-surface/SKILL.md b/.agents/skills/sdp-agent-surface/SKILL.md index 2937a6fa..35a688cb 100644 --- a/.agents/skills/sdp-agent-surface/SKILL.md +++ b/.agents/skills/sdp-agent-surface/SKILL.md @@ -1,6 +1,6 @@ --- name: sdp-agent-surface -description: Query this repository's Spec graph through `sdp q` instead of reading spec files by hand. Use whenever a question is about the authored corpus — what a Spec says or guarantees, who verifies it, what is ready but unimplemented, what a change touches, what is still open or blocking, what a Spec depends on, where a concept lives, which Specs are in a Pack, what a component contains or uses, what the census or projections will see, or what the validation report says. Also use before editing `.sdp.md` files, before writing a Spec citation, and before answering "is this implemented / verified / ready". +description: Query this repository's Spec graph through `sdp q` instead of reading spec files by hand. Use whenever a question is about the authored corpus — what a Spec says or guarantees, who verifies it, what is ready but unimplemented, what a change touches, what is still open or blocking, what a Spec depends on, where a concept lives, which Specs are in a Pack, what a component contains or uses, what code references a design, what the census or projections will see, or what the validation report says. Also use before editing `.sdp.md` files, before writing a Spec citation, and before answering "is this implemented / verified / ready". --- # The agent surface @@ -22,24 +22,35 @@ every question is a filter or a join, never a tree walk. Four node types exist: - `Pack` — the review grouping: title, framing prose, `modelRefs`. Membership is `belongsTo` edges. A Pack states no system truth. - `Anchor` — a test or oracle binding, with the `file` and `line` of the binding itself. -- `CodeNode` — the code identity a `codeAnchor` mints; structural `memberOf` and `uses` edges run - between these. - -Eleven edge types exist, and the list is closed. Six are authored Spec relations: `refines`, -`dependsOn`, `constrainedBy`, `decidedBy`, `verifies`, `supersedes`. Five are derived by -extraction: `belongsTo` (Pack membership), `satisfies` (code realization), `models` (oracle -binding), `memberOf` and `uses` (anchored structure). A relation name outside this list is a bug -in whatever prose named it, not a query to attempt. IDs are namespaced (`spec:` · `pack:` · -`impl:` · `api:` · `component:` · `test:` · `oracle:`), and an edge whose target does not resolve -confers no delivery fact. Every node and edge carries exactly one claim; the edge contract is +- `CodeNode` — the code unit a code anchor mints, from either form: a `codeAnchor` constant or a + comment-form block whose id sits in the `impl:`, `api:`, or `component:` namespace. It carries + `file`, `line`, an optional `label`, and three optional structural attributes: `role`, the + architectural pattern the unit plays, on any unit; and on a `component:` unit only, `layer` (one + of `edge`, `application`, `domain`, `adapter`, `infrastructure`) and `context`, its bounded + context. None of the three confers anything. Structural `memberOf` and `uses` edges run between + these units. + +Twelve edge types exist, and the list is closed. Six are authored Spec relations: `refines`, +`dependsOn`, `constrainedBy`, `decidedBy`, `verifies`, `supersedes`. Six are derived by +extraction: `belongsTo` (Pack membership), `satisfies` (code realization, one edge per target), +`references` (code that answers to a design), `models` (oracle binding), `memberOf` and `uses` +(anchored structure). A `references` edge runs from a code unit to a Spec and says only that the +code answers to that design without claiming to realize it: it confers no delivery fact, moves no +readiness floor, and the drift alarm ignores it. It runs one way. A design that builds on existing +code states that through its own `dependsOn` or `refines` to the Spec the code satisfies, so read +that dependency from the Spec's relations, never from the code. A relation name outside this list +is a bug in whatever prose named it, not a query to attempt. IDs are namespaced (`spec:` · `pack:` +· `impl:` · `api:` · `component:` · `test:` · `oracle:`), and an edge whose target does not +resolve confers no delivery fact. Every node and edge carries exactly one claim; the edge contract is `spec:extraction.derive-graph`, the claim law is `spec:extraction.claim-taxonomy`. ## How delivery state derives Every fact enters the graph through one of three claims, and the claims are never collapsed. Carrier prose, relations, and stated readiness are `declared` intent. Source anchors are `anchored` -bindings: a `codeAnchor` records that this code realizes that Spec, a `specTest` records that this -test verifies its target Spec, a `specOracle` records that this function models that example space. +bindings: a code anchor's `satisfies` records that this code realizes that Spec and its `references` +records only that the code answers to it, a test anchor records that this test verifies +its target Specs, an oracle anchor records that this function models that example space. Structure the extractor computes on its own enters as `inferred`. Delivery facts fall out of those edges. A Spec is `implemented` when a `satisfies` edge resolves to @@ -85,10 +96,10 @@ The public projection publishers are `sdp view`, `sdp census`, `sdp mermaid`, an In this source checkout, use `npm run generate:self-hosting` or `npm run check:self-hosting` when all four roots must be published or certified together. -The catalog contains twenty-six ready-made bodies in `docs/agent-surface/recipes.md` in the +The catalog contains twenty-eight ready-made bodies in `docs/agent-surface/recipes.md` in the Protocol repository and `node_modules/@libar-dev/software-delivery-protocol/docs/agent-surface/recipes.md` in an adopter. -Recipes 1-26 each open under a numbered heading that names the recipe. Every body there runs +Recipes 1-28 each open under a numbered heading that names the recipe. Every body there runs verbatim and a test proves it. Start from a recipe; adapt it in place. For structural questions, use component membership, uses fan-in and fan-out, structural @@ -96,7 +107,14 @@ neighborhood, census structural coverage, and the projection-coverage upper boun For architecture questions, use the architecture map to see components and their shaping decisions together, the decision map to rank decisions by shaping fan-in (decided subjects plus inter-decision dependsOn and refines), or the planning slice to see refinement and dependency neighbors, shaping -decisions, bound components, and entry points before editing. +decisions, bound components, and entry points before editing. To read a Pack from the code side, +run references into a design (recipe 27): one row per member with the units that reference it, +the units that realize it, whether a verifier is bound, and a derived "builds on" column, the +Specs the member `dependsOn` or `refines` that carry `implemented`, each with its implementing +units. The four columns are independent and form no ladder, and "builds on" confers nothing. An +empty list reads as unbound, never as "not built". For the architecture vocabulary itself, run +roles, layers and contexts (recipe 28): every value the code anchors state, with the units that +carry it, and the `references` edges counted apart from units. For a table you would otherwise keep by hand, run a register recipe each time you need it: the open-question register (recipe 20) for every open question with its blocking flag and its key, @@ -120,6 +138,19 @@ path through it, and reports without refusing. Reach for the files only when you need the authored prose itself — the exact words to edit. +## The entry adapters + +`g.specContext(id)` lists the units that realize the Spec under `implementations` and the units +that answer to it under `references`, each with its file and line. `g.byFile(path)` returns every +node recorded at a path and the Specs reachable from it. A comment-form anchor is recorded at its +own site, so a source file with no Protocol import still answers. A node may come back with an +empty Spec list: an identity-only anchor, with no `satisfies` and no `references`, is lawful and +binds no Spec, and the empty list is its honest answer, not a failed lookup. +`g.blastRadius(files)` follows `satisfies`, `references`, `verifies`, and oracle `models` from the +changed files and names the edge type and claim in each reason. A changed unit bound to no Spec +goes in `unlinked`; a changed file the graph records nothing at goes in `coverageUnknown`. Neither +list implies coverage. + ## The contract `sdp q [''] [--root PATH] [--exclude PATH]... [--json]` @@ -167,6 +198,9 @@ are empty and its gap warnings say nothing about what the Protocol has built. Pass, fail, skip, and quarantine are CI's. - **Do not read `implemented` as "it is live."** It says a code anchor binds to the Spec. Runtime evidence would be `observed`, the designed-and-deferred liveness fact the graph does not derive. +- **Do not read `references` as `implemented`.** A `references` edge says the code answers to + the design, and nothing more. Only a resolving `satisfies` confers `implemented`; recipe + 27 keeps the two in separate columns. - **Do not use raw `ready ∧ ¬implemented` as the operational backlog.** Under the example realization posture and the decision readiness posture it also includes ready example evidence and ready decision records; recipe 1 excludes both kinds, reports the excluded counts, and audits example @@ -184,5 +218,6 @@ are empty and its gap warnings say nothing about what the Protocol has built. The Protocol's ratified glossary is `CONTEXT.md` at the Protocol repository's root, and `node_modules/@libar-dev/software-delivery-protocol/CONTEXT.md` in an adopter. It is separate from any glossary your project keeps. Read it before inventing a term. The terms these queries speak: -`Spec` · `Pack` · `anchor` · `claim` · delivery facts · readiness floor · derived readiness · -blast radius · at-risk · coverage-unknown · gap · orphan. +`Spec` · `Pack` · `anchor` · constant form · comment form · `claim` · `references` · role · layer · +context · delivery facts · readiness floor · derived readiness · blast radius · at-risk · +coverage-unknown · gap · orphan. diff --git a/.agents/skills/sdp-authoring/SKILL.md b/.agents/skills/sdp-authoring/SKILL.md index 47e816e9..bab18bac 100644 --- a/.agents/skills/sdp-authoring/SKILL.md +++ b/.agents/skills/sdp-authoring/SKILL.md @@ -188,19 +188,32 @@ membership order and point to `spec:carrier.markdown-pack-authoring` for the com ## Bind code, tests, and oracles -Anchors are the only write path from code into the graph, and the two ways to get them wrong are -both silent: nothing fails, the binding just never exists. Three builders exist, each carrying -identity, an optional label, and one realization target: - -- `codeAnchor` binds implementation code through `satisfies`, with IDs in the `impl:`, `api:`, or - `component:` namespaces. -- `specTest` binds a test through a non-empty `verifies`, in the `test:` namespace. A resolving - `specTest` anchor is the sole `has-verifier` source, conferring the fact directly on the Spec it - verifies or, through an enabled example, on the Spec that example verifies. -- `specOracle` binds an oracle through `models`, in the `oracle:` namespace. It records that +Anchors are the only write path from code into the graph. Malformed authoring the extractor +recognizes is an error: a field beyond the contract, a misspelled or misplaced reserved tag, a +target that does not resolve. Two mistakes stay silent, because the extractor never recognizes +them as authoring: a constant-form builder that is not a Protocol builder binding, and a comment +that does not open with `/**`. Then nothing fails and the binding just never exists. An anchor +carries identity, an optional label, its targets, and optional structure. It never carries behavior, rationale, readiness, +status, acceptance criteria, or delivery facts, and a field beyond that contract is an extraction +error. The law is `spec:model.anchors` and `spec:decisions.binding-not-liveness`. The id's +namespace selects one of three flavors: + +- A code anchor (`impl:`, `api:`, `component:`) binds implementation code. Its `satisfies` is + optional and plural; each resolving target derives a `satisfies` edge and confers `implemented` + on that Spec. Its `references` names the Specs the code answers to without claiming to realize + them, and confers nothing. +- A test anchor (`test:`) binds a test through a non-empty, plural `verifies`. A resolving test + anchor is the sole `has-verifier` source, conferring the fact directly on each Spec it verifies + or, through an enabled example, on the Spec that example verifies. +- An oracle anchor (`oracle:`) binds an oracle through one `models` target. It records that expected-outcome semantics exist and confers no delivery fact. -The anchor-constant form, written out once: +Each anchor takes one of two forms, and both feed one closed envelope. Pick the form by what the +file may import. + +### The constant form + +Code that may import the package writes a top-level `const` initialized with a builder call: ```ts import { @@ -213,40 +226,154 @@ import { const createOrderAnchor = codeAnchor({ id: codeAnchorId("impl:orders.create-order"), label: "realizes order creation", - satisfies: ref("spec:orders.create-order"), + satisfies: [ref("spec:orders.create-order"), ref("spec:orders.order-numbering")], + references: [ref("spec:orders.checkout-flow")], component: componentAnchorId("component:orders.api"), uses: [codeAnchorId("impl:orders.repository")], + role: "service", }); void createOrderAnchor; ``` -A top-level `const`, a trusted package import, one realization target, and optional structure. The -`void` reference keeps the unused constant past lint without exporting it. - -The first hazard is form. The extractor reifies only the anchor-constant form above: a top-level -`const` initialized with the builder call. The decorator and JSDoc forms remain unextracted -representations and mint nothing. - -The second hazard is trust. The builder import must be a Protocol builder binding: from the public -package, or from a relative import that resolves to the package's `ids` or `model/code-anchor` -module. A consumer-local lookalike mints nothing and reports nothing, because a source file that -never bound to the Protocol is not authoring drift to report. On the CommonJS package surface the -trusted relative-module set is empty, so relative bindings mint no anchors there while package -imports stay trusted. When an authored anchor fails to appear in the graph, suspect the import -before the syntax. - -An anchor never carries behavior, rationale, readiness, acceptance criteria, or delivery facts; a -field beyond that contract is an extraction error. The law is `spec:model.anchors` and -`spec:decisions.binding-not-liveness`. - -A `codeAnchor` may also declare structure through two fields: one `component?: ComponentAnchorId` -and a non-empty, unique `uses?: readonly CodeAnchorId[]`. Both are closed graph-ID references that -must resolve to an existing `CodeNode`, and they derive only anchored `memberOf` and `uses` edges. -`memberOf` runs from an `impl:` or `api:` node to a `component:` node with at most one component -per source, structural self-reference is refused, and a malformed structural field refuses the whole -anchor. There is no `implements` field; contract realization stays `satisfies`. Multi-node `uses` -cycles remain data, never findings, and structural edges confer no intent, delivery fact, or -readiness effect. The law is `spec:decisions.structural-anchor-semantics`. +`specTest` takes `id: testAnchorId("test:…")` and `verifies`; `specOracle` takes +`id: oracleAnchorId("oracle:…")` and `models`. `satisfies` and `verifies` take one `ref(…)` or a +fresh array literal of them; `references` and `uses` take a fresh array literal. The `void` +reference keeps the unused constant past lint without exporting it. + +A runtime that may import but must not load `node:*` modules or ts-morph, such as a browser bundle +or a sandboxed function runtime, imports the same builders from the zero-dependency subpath +`@libar-dev/software-delivery-protocol/anchors`. It exports the id builders and the three anchor +builders and nothing else. + +Trust comes from the import. The builder import must be a Protocol builder binding: the public +package, its `/anchors` subpath, or a relative import that resolves to the package's `ids` or +`model/code-anchor` module. A consumer-local lookalike mints nothing and reports nothing, because +a source file that never bound to the Protocol is not authoring drift to report. On the CommonJS +package surface the trusted relative-module set is empty, so relative bindings mint no anchors +there while package imports stay trusted. When a constant-form anchor fails to appear in the +graph, suspect the import before the syntax. + +### The comment form + +Code that may import nothing from the Protocol writes a top-level `/** … */` block in a `.ts` or +`.tsx` file, carrying reserved `@sdp` tags. No import is required: + +```ts +/** + * Rebuilds the read model from the event history, one stream at a time. + * + * @sdpAnchor impl:orders.read-model-rebuild + * @sdpLabel the online rebuild path + * @sdpSatisfies spec:orders.rebuild + * @sdpReferences spec:orders.read-model, spec:decisions.replay-per-stream + * @sdpComponent component:orders.read-model + * @sdpUses impl:orders.event-store, impl:orders.projection-gate + * @sdpRole service + */ +export async function rebuild(stream: string): Promise { + await replay(stream); +} +``` + +- `@sdpAnchor ` opens the anchor, once per block, and its namespace selects the flavor. +- Target tags by flavor: `@sdpSatisfies` and `@sdpReferences` on a code anchor, `@sdpVerifies` on + a test anchor, `@sdpModels` with one target on an oracle anchor. A list is comma-separated, + non-empty, and repeats no target. +- Structure tags, code anchors only: `@sdpComponent`, `@sdpUses`, `@sdpRole`; a `component:` + anchor also takes `@sdpLayer` and `@sdpContext`. `@sdpLabel` fits any flavor. +- Prose comes first and authors nothing. After the first tag every non-empty line opens a tag, + so a target wrapped onto a second line is refused rather than read as prose; an ordinary TSDoc + tag such as `@param` stays lawful and is not read. Each tag appears at most once. +- A line that opens with `@sdp` is a reserved tag, spelled exactly: `@sdp-anchor` or + `@sdpAnchor:` is an error, never prose. Any other `@sdp` tag, a block with tags and no + `@sdpAnchor`, and a tagged `/** … */` block inside a function body, a class member, an object + literal, or a JSX expression are errors. A comment that opens with `/*` alone is never read. +- The block's first line is the binding's file and line, so `byFile` on the source file names the + anchor. The same id written in both forms is a duplicate id. +- The block binds by file and line, never by the statement beneath it. Delete or move that + statement and the block still binds, conferring what it did; a stale or wrong target that still + resolves is an authoritative binding until someone edits the block. Review a block with the code + beneath it. +- The file is the binding grain of both forms. A block names no declaration, and the graph records + no symbol, so keep a file's blocks together and write one identity per realizing site rather than + one block above each declaration. +- `sdp validate --watch` re-runs on carrier edits only, never on source edits. After editing an + anchor, run `sdp validate` or query the graph again. + +The law is `spec:decisions.anchor-comment-form`. The decorator form stays an unextracted +representation and mints nothing. + +### Choose targets honestly + +`satisfies` claims the code realizes the whole Spec and confers `implemented`. `references` says +the code answers to the design without claiming to realize it: the unit follows the design or +realizes part of it, so a change to the design asks the code to follow. It confers nothing: no +delivery fact, no readiness floor, no drift alarm. A target named in both is an error. A code +anchor with neither is identity-only and lawful; it mints a code unit for structure and for +`byFile`, and confers nothing. + +A reference runs one way, from the code to the design it answers to. When a new design builds on +code that already exists, the design says so with `dependsOn` or `refines` to the Spec that code +satisfies. The existing code gets no reference to the new design. + +Targets name whole Specs; an entry address is never a target. To choose between `satisfies` and +`references` for one Spec, list what the Spec promises that no satisfying unit realizes. Read every +promise it carries, not only its Design entries: a rule, a flow, a contract entry, a UI entry, or a +constraint promises as much, and a Spec without a Design section still promises. + +- None: keep `satisfies`. +- A coherent part with its own trigger or step: split it into a child Spec that refines the + parent, then run the pass again on what the parent still promises before it keeps `satisfies`. +- Anything else: write `references` until the code realizes what the Spec promises. + +Never write a whole-Spec `satisfies` over a promise no unit builds. Apply the same pass to a +component and its members together. + +Write a reference for the design relationship it names. A reference added to make a test pass, +move a count, or give `byFile` an answer does not belong; remove it. Never point `satisfies` at +an unfinished Spec to manufacture coverage: a unit that answers to such a Spec, a stub included, +references it. Code never satisfies a decision Spec directly; a unit may reference the decisions it +follows. The law is `spec:decisions.anchor-binding-grain`. + +### Declare architecture where it is realized + +- `component` names the one `component:` anchor a unit belongs to, and `uses` lists the units or + components it depends on. Both are closed graph-ID references that must resolve to an existing + code unit, and they derive only anchored `memberOf` and `uses` edges. `memberOf` runs from an + `impl:` or `api:` unit to a `component:` unit, at most one per source. A present `uses` is + non-empty and unique, and structural self-reference is refused. Multi-node `uses` cycles remain + data, never findings. There is no `implements` field; contract realization stays `satisfies`. +- `role` names the architectural pattern the unit plays, on any code anchor. The corpus owns the + vocabulary: `service`, `decider`, `projection`, `read-model`, `codec`, `contract`, `barrel`, and + `utility` are the lineage set, not a closed list. A value is one lowercase kebab token matching + `^[a-z][a-z0-9-]*$` and is never normalized, so `readModel` is refused rather than folded into + `read-model`. +- `layer` goes on a `component:` anchor only and takes one of five values: + - `edge`: the transport and presentation boundary. + - `application`: use cases and orchestration. + - `domain`: the model and its rules. + - `adapter`: the implementations of ports toward external systems. + - `infrastructure`: runtime, persistence, and platform plumbing. +- `context` goes on a `component:` anchor only and names its bounded context, in the token grammar + a role uses. + +A malformed or non-static structural field, an unknown `layer`, or `layer` or `context` on a +non-component anchor refuses the whole anchor. Every structural field is optional, and none +confers intent, a delivery fact, or a readiness effect. A component that no code realizes yet is a +Spec, never an invented anchor. A component either satisfies its seam's design Spec or binds no +Spec its members satisfy; it never references one. A package may hold several layers or contexts, +so map packages to components by review rather than one component per package. Run roles, layers and contexts +(recipe 28) to see the values in use before you add one. The law is +`spec:decisions.architectural-annotation`. + +### Curate the anchors + +Annotations are curated, never a coverage quota. Anchor the units that carry the architecture, its +exported public surface and its cross-component reach, and leave the rest bare. An identity has +one owner: one anchor per realizing unit, so an identity that would stand for several files +becomes several identities, each choosing `satisfies` or `references` for what its own code does. +A realizing unit's comment documents its local how, never a paraphrase of the Spec's what or why; +the Spec already owns those, and a restatement in code is a second owner that drifts. A Markdown deliverable cannot carry an in-code anchor. Bind it through the document-realization convention: the test suite that asserts the shipped document carries the code anchor, its label diff --git a/.agents/skills/sdp-sessions/SKILL.md b/.agents/skills/sdp-sessions/SKILL.md index 06f8ceb9..e4ae3721 100644 --- a/.agents/skills/sdp-sessions/SKILL.md +++ b/.agents/skills/sdp-sessions/SKILL.md @@ -64,10 +64,15 @@ Use the build backlog (recipe 1) to orient the available ready work and the targ (recipe 3) to read guarantees, relations, implementation bindings, and verifiers. Inspect the planning slice (recipe 19) to see refinement neighbors, bound components, and entry points before writing code. When the work lives in a declared seam, use component membership (recipe 12) and uses -fan-in/fan-out (recipe 13) to see the units and neighbors before binding. Bind code, test, and oracle -anchors, the structural `component` and `uses` fields included, and executable examples through -`sdp-authoring`, which owns the registrar-first executable transition; an `implemented` fact names -a binding, not a passing or live system. +fan-in/fan-out (recipe 13) to see the units and neighbors before binding. When the code answers +to a Pack's design, use references into a design (recipe 27) to see which units already reference +or realize each member and which implemented Specs each member builds on, and roles, layers and +contexts (recipe 28) to reuse a role, layer, or context value before coining one. Bind code, test, and oracle anchors in either form, with `satisfies`, +`references`, `role`, and the structural `component` and `uses` fields, and executable examples +through `sdp-authoring`, which owns the registrar-first executable transition; an `implemented` +fact names a binding, not a passing or live system, and a `references` edge confers nothing. A +reference runs one way, from code to the design it answers to; when a new design builds on +existing code, the design's own `dependsOn` or `refines` says so, never a reference from the code. ### Review @@ -78,8 +83,11 @@ mention audit (recipe 22) with that scope as its parameter. Use address resoluti the entry addresses a register, a test, or a page cites outside the Specs. When reviewing component, architecture, or projection questions, use structural neighborhood (recipe 14), census structural coverage (recipe 15), the projection-coverage upper bound (recipe 16), the architecture -map (recipe 17), and the decision map (recipe 18). Review findings and gaps as data; the review -never becomes a workflow gate. +map (recipe 17), and the decision map (recipe 18). For a Pack reviewed from the code side, use +references into a design (recipe 27): per member, the referencing units, the realizing units, and +whether a verifier is bound, with an empty column read as unbound. Use roles, layers and contexts +(recipe 28) to review the architecture vocabulary the anchors state and to spot two spellings of +one value. Review findings and gaps as data; the review never becomes a workflow gate. ### Close / slim diff --git a/.prettierignore b/.prettierignore index 07342348..313fb66b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -21,6 +21,7 @@ explorations/ # 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 +test/fixtures/extract/annotation-forms/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 diff --git a/AGENTS.md b/AGENTS.md index 9390d3c0..406935d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,11 @@ authoritative for what the Protocol claims; **`src/` and tests** are authoritati current realization. A disagreement is **drift to resolve**, never permission to silently promote code behavior into intent. +> **plan 40 is EXECUTED**. The annotations arc: the comment form (MD-36), optional plural +> bindings and design references (MD-37), and architecture attributes (MD-38), all three +> `ready`, with their open questions closed by the plan's rulings; the engine, self-binding, +> projections, and agent recipes changed together. Counts are derived at close; re-run the +> recipes rather than quoting them. > **plan 39 is DRAFTED**. The first-adopter arc, a thin lineage pointer in the plan-38 shape. Six > of its Specs (inline code spans, the unbound-example posture, the typed-dependency floor, the > register recipes, the adopter on-ramp, the shipped corpus) are implemented with bound evidence @@ -84,7 +89,7 @@ Progressive disclosure — start at the top, follow the pointers down. | Look here | What you get | Read | |---|---|---| | `CONTEXT.md` (repo root) | **the vocabulary** — the ratified lean glossary (terms · relations · a worked dialogue · flagged ambiguities); sole source of truth for terminology; the model exposition lives in the Specs under `specs/` and in the surviving concept docs | **first, always** | -| `.agents/skills/` + `docs/agent-surface/recipes.md` | **the agent on-ramps** — three repository-owned skills: `sdp-agent-surface` (reading the graph), `sdp-authoring` (authoring intent), `sdp-sessions` (advisory delivery-session routing), also exposed to Claude through the `.claude/skills` symlink; plus the twenty-six runnable `sdp q` bodies | **mandatory** — after `CONTEXT.md`, load the matching skill before any corpus question, Spec authoring, or delivery-session routing; see "Query the graph first" | +| `.agents/skills/` + `docs/agent-surface/recipes.md` | **the agent on-ramps** — three repository-owned skills: `sdp-agent-surface` (reading the graph), `sdp-authoring` (authoring intent), `sdp-sessions` (advisory delivery-session routing), also exposed to Claude through the `.claude/skills` symlink; plus the twenty-eight runnable `sdp q` bodies | **mandatory** — after `CONTEXT.md`, load the matching skill before any corpus question, Spec authoring, or delivery-session routing; see "Query the graph first" | | `specs/` | **the self-hosted corpus** — the Protocol's own Specs in its own carrier (families: `model` · `extraction` · `validation` · `carrier` · `consumers` · `protocol` · `observation` · `decisions`, plus the self-hosting Pack); the primary carrier of intended truth | when design truth is in question — but query it through `sdp q` first, then read the carrying Spec | | `jtbd-stories/` | **the jobs (functional spec)** — stable `When / I want / so I can` stories (themes A–H); no personas, because consumers are heterogeneous (humans, CI, CLIs, **AI agents**) | to know *what* we serve | | `docs/concept/` (+ README) | **the technical design** — the surviving principle-led docs: vision & MVP boundary, founding principles (P1–P10), authoring & binding, consumers, roadmap; the core model, the one graph, and validation & honesty dissolved into the `model.*`, `extraction.*`, and `validation.*` Spec families — locate any of them with concept search (recipe 6) | to know *how* it is designed | @@ -97,7 +102,7 @@ Progressive disclosure — start at the top, follow the pointers down. | `npm run check` | **the green gate** — `check:temporal` → `lint` → `format:check` → `build` → `generate:self-hosting` → `generate:example` → `typecheck` → `typecheck:examples` → `test` → `check:self-hosting-gates` → `check:self-hosting` → `check:example` → `preflight` | before claiming green / after engine edits | | `reviews/` | **archived session reviews** (implementation, founding-ideation, adversarial + prompts) — durable findings already folded into plans/DECISIONS; read for provenance | rarely | -Key decisions (names first): the executable meta-model (MD-1); one primitive named coordinates (MD-4); the carrier ruling (MD-18); the agent front door (MD-22); structural anchors confer nothing (MD-30); the shipped projections stay frozen (MD-32); planning truths live in ruled graph homes (MD-33); architectural significance rides existing primitives (MD-34). +Key decisions (names first): the executable meta-model (MD-1); one primitive named coordinates (MD-4); the carrier ruling (MD-18); the agent front door (MD-22); the shipped projections stay frozen (MD-32); planning truths live in ruled graph homes (MD-33); the comment form (MD-36); bindings are optional, plural, and may reference a design (MD-37); architectural significance is annotated where it is realized (MD-38). > Concept docs still carry implementation detail (TS shapes, DSL, graph JSON) for **unsettled and > post-MVP** design — on a disagreement with `src/`, the drift rule above applies: fix the stale @@ -132,7 +137,7 @@ The full CLI surface is `sdp build · validate · view · census · mermaid · g and stays alive after findings; operator stop exits 0. `--watch` is validate-only and cannot combine with `--check-clean`. The four projection publishers remain independent public verbs; repository generation/check scripts certify all four roots through the private projection-suite driver. The -twenty-six runnable recipe bodies live in `docs/agent-surface/recipes.md` (each executed as written by +twenty-eight runnable recipe bodies live in `docs/agent-surface/recipes.md` (each executed as written by `test/recipes.test.ts`), and the repository-owned skills (`sdp-agent-surface` for reading, `sdp-authoring` for writing intent, `sdp-sessions` for advisory work-shape routing) are the on-ramps. In this checkout, always go through diff --git a/CONTEXT.md b/CONTEXT.md index 32ceef65..bff56752 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -64,8 +64,14 @@ The familiar delivery nouns are **named coordinates on the one primitive, never | Term | Definition | Aliases to avoid | |---|---|---| | **`Pack`** | the authored grouping / review **aggregate** over `Spec`s; states **no truth of its own**; its `framing` is a plain descriptive note; a spec may belong to many packs; membership lives on the manifest, the `belongsTo` edge is derived from it | "SpecPack" | -| **anchor** | a human-written pointer **in source code**: *"this code location is the implementation/test **binding** for this Spec ID"* — a binding assertion only, never system-truth content (no behavior, rationale, readiness, acceptance criteria, or delivery facts); yields `anchored`-claim edges | "marker" | -| **architecturally significant unit** | a code unit with exported public surface or cross-component reach that warrants graph-visible structural binding — component membership, and uses declarations for its architectural dependencies; the accepted set is an owner-reviewed declaration, never derived from imports (carried by `spec:model.structural-patterns`) | "pattern" (refused, MD-34) | +| **anchor** | a human-written pointer **in source code**: *"this code location is a **binding** into the graph"* — identity, an optional label, zero or more Spec targets, and optional structure; a binding assertion only, never system-truth content (no behavior, rationale, readiness, status, acceptance criteria, or delivery facts); yields `anchored`-claim edges; written in one of **two representations**, the constant form or the comment form, feeding one closed envelope (`spec:model.anchors`); in prose, the verb **"annotate"** means "write an anchor" | "marker" · "annotation" | +| **constant form** | the anchor as a top-level `const` initialized with a trusted builder call (`codeAnchor` · `specTest` · `specOracle`), reified statically; trust is by Protocol builder import, from the package or its zero-dependency `/anchors` subpath | "anchor constant" (the old name) · "marker constant" | +| **comment form** | the anchor as a top-level `/** … */` block in a `.ts`/`.tsx` file, above a statement, after the last one, or alone in the file, carrying reserved camelCase, TSDoc-compatible tags (`@sdpAnchor` · `@sdpSatisfies` · `@sdpReferences` · `@sdpRole` · …); `@sdpAnchor ` opens it, only the reserved tags are read, prose authors nothing, and no import is required (the comment-form ruling, MD-36) | "JSDoc anchor" · "doc tag" | +| **references** | the derived, `anchored` CodeNode → `Spec` edge from a code anchor's `references` list: *this code answers to that design* without claiming to realize it; the unit follows the design or realizes part of it, so a change to the design asks the code to follow; one direction only, so a design that builds on existing code says so by its own `dependsOn` or `refines` to the Spec the code satisfies, never by a reference from the code; a target names a whole Spec; it confers no delivery fact, moves no floor, and the drift alarm ignores it (the binding-grain ruling, MD-37) | "see-also" · "satisfies" (a realization claim) · "dependsOn" (a Spec relation) | +| **role** | the optional one-token value on a code anchor, lowercase kebab and never normalized, naming the architectural pattern its unit plays (`service` · `decider` · `projection` · …); a corpus-owned vocabulary whose taxonomy the census renders with counts, checked against no list (the architectural-annotation ruling, MD-38) | "pattern" · "kind" (reserved for the Spec descriptor) · "tag" | +| **layer** | the optional closed-set attribute of a `component:` anchor — `edge` · `application` · `domain` · `adapter` · `infrastructure`; outside the set, or on a non-component anchor, an envelope error | "tier" | +| **context** | the optional one-token value on a `component:` anchor, in the token grammar of `role`, naming its bounded context; on a non-component anchor, an envelope error | "bounded_context" (the v0 field) · "domain" | +| **architecturally significant unit** | a code unit with exported public surface or cross-component reach that warrants graph-visible structural binding — component membership, uses declarations for its architectural dependencies, a role, and references to the designs it answers to; annotated on the anchor of the code that realizes it; the accepted set is an owner-reviewed declaration, never derived from imports (carried by `spec:model.structural-patterns`) | "pattern" (refused by the architectural-annotation ruling, MD-38, which supersedes MD-34) | **Two grouping mechanisms, kept distinct:** *refinement* (parent `Spec` → children — authored truth with descendants) vs *the aggregate* (the `Pack` — a cross-cutting review collection, no truth of its own). @@ -76,7 +82,7 @@ descendants) vs *the aggregate* (the `Pack` — a cross-cutting review collectio |---|---|---| | **`claim`** | a fact's **epistemic status** — never its ancestry; values `declared` / `anchored` / `inferred`, **never collapsed** | "provenance" | | **declared** | human-authored *intent* (relations, readiness, decisions) — authoritative intent | — | -| **anchored** | a human *binding* — an anchor points code → a `Spec` ID — authoritative binding, no intent | — | +| **anchored** | a human *binding* — an anchor binds a code unit, test, or oracle into the graph, pointing at zero or more `Spec` IDs (an identity-only code anchor binds structure alone) — authoritative binding, no intent | — | | **inferred** | machine-derived *structure* (calls, imports) — advisory, never authoritative | — | | **authored / derived** | the umbrella pair: authored = human claims (`declared` + `anchored`); derived = machine output | — | @@ -89,7 +95,7 @@ claim category** — it **inherits** its source's `claim` (so `belongsTo` carrie |---|---|---| | **delivery fact** | a derived truth about a `Spec`'s *realization*, computed from edges, shown as a badge — **never authored** (authoring one is an honesty violation) | a readiness rung | | **`implemented`** | ≥1 `satisfies` edge resolves directly to the Spec — code **claims** to realise it, *not* that it works or is live; the fact never propagates through refinement | — | -| **`has-verifier`** | ≥1 `verifies` edge from an **enabled verifier** resolves to the Spec — a verifier *exists*, *not* that it passed | — | +| **`has-verifier`** | a resolving test anchor `verifies` the Spec directly, or an **enabled verifier** verifies it — a verifier *exists*, *not* that it passed | — | | **`observed`** *(aspirational)* | runtime evidence links to the Spec's target — the liveness rung | — | | **enabled verifier** | a verifying `example`/scenario backed by a **linked, resolvable test anchor** — *structurally bound*, not runner-executed (skip/quarantine is CI's, exactly as pass/fail is) | — | @@ -136,7 +142,7 @@ validated," never "provably correct."** | **evaluation sink** | the front door's CLI entrance, the verb `sdp q`: derives the graph in process on every invocation, injects `g` / `graph` / `report`, evaluates the operator-supplied async body, prints its `return`; writes nothing and mints no query vocabulary | a REPL · a second read model | | **demand map** | the ruled statement of how agents actually arrive — holding a **string**, a **file**, or a **changeset**, never the Spec id they are looking for — carried as clauses on `spec:consumers.agent-surface`; the reason the frozen entry adapters are `findByConcept` · `byFile` · `blastRadius` | — | | **recipe** | a runnable, documented `sdp q` body composing the frozen adapters — the growth valve past them: when a question is not answered, script it, and a join freezes into the `reader` only at the second-caller bar; catalog at `docs/agent-surface/recipes.md`, every body executed as written by the recipe check | a new query verb | -| **blast radius** | the reader's file-level impact query: a changeset → the directly impacted `Spec`s/`Pack`s (authored at, or bound to, the changed files) + the explicit one-hop neighborhood; never claims exhaustive reach | — | +| **blast radius** | the reader's file-level impact query: a changeset → the directly impacted `Spec`s/`Pack`s (authored at, or bound to, the changed files) + the explicit one-hop neighborhood; a changed identity-only CodeNode, bound to no Spec, is reported in its own bucket, neither dropped nor folded into coverage-unknown; never claims exhaustive reach | — | | **coverage-unknown** | the honest blind spot of file-level impact: a changed file the graph records nothing at, **named in the result** — never silently dropped | — | | **at-risk** | a node one explicit hop from an impacted node (the connecting edge + its `claim` carried), itself neither impacted nor at a changed file | — | | **context bundle** | a token-budgeted curated slice pushed to an agent | — | @@ -169,7 +175,7 @@ a sibling set back as a table — the graph never holds a multi-point example. | Term | Definition | Aliases to avoid | |---|---|---| -| **carrier** | the text format that carries the authored `Spec` document — the envelope, the prose, and the owned notation; **ruled: Markdown (`.sdp.md`) for all eight kinds**, Gherkin (`.sdp.gherkin`) as a lawful per-ID carrier for behavior and example Specs, and the TS DSL as the import source and a lawful per-ID option (one canonical surface per ID, `04` §1); bare `.feature` is non-canonical import-source territory | "format"/"file type" (say which layer) · a carrier is never a second store | +| **carrier** | the text format that carries the authored `Spec` document — the envelope, the prose, and the owned notation; **ruled: Markdown (`.sdp.md`) for all eight kinds**, Gherkin (`.sdp.gherkin`) as a lawful per-ID carrier for behavior and example Specs, and the TS DSL as the import source and a lawful per-ID option (one canonical surface per ID, `04` §1); bare `.feature` is non-canonical import-source territory | "format"/"file type" (say which level) · a carrier is never a second store | | **notation** | the Protocol-**owned** typed step language inside the carrier's fenced blocks — Given/When/Then step text and the slot vocabulary; owned by the Protocol whatever the carrier | "grammar" (the dismissed own-language direction) · "DSL" (reserved for the TS DSL) | ## Relations (authored, typed, directed `Spec`→`Spec` edges — → `spec:model.relations`) @@ -183,8 +189,11 @@ a sibling set back as a table — the graph never holds a multi-point example. | **verifies** | verifier → target | an example/scenario verifies a target — also emitted derived from a test anchor (same type, different `claim`, never collapsed) | UML «verify» | | **supersedes** | new → old | current forward-pointer between two Decision Records that both still exist | — | -**Derived, never authored:** `satisfies` (code → `Spec`, from an anchor) · `belongsTo` (→ grouping, from the -manifest). **Dropped:** `exemplifies` (= `example` kind + `refines` + `verifies`). +**Derived, never authored:** `satisfies` (code → `Spec`, from an anchor; one edge per target) · `references` +(code → `Spec`, from an anchor; confers nothing) · `models` (oracle → `Spec`) · `belongsTo` (→ grouping, from the +manifest) · `memberOf` and `uses` (CodeNode → CodeNode, anchored structure). The edge list is **closed at +twelve**: these six plus the six authored relations above (`spec:extraction.derive-graph`). **Dropped:** +`exemplifies` (= `example` kind + `refines` + `verifies`). ## Delivery-process vocabulary (adopted nouns, realized only as projections — → `06` §6) @@ -249,7 +258,9 @@ delivery fact, workflow state, or graph state. `carrier` · `notation` (both ratified at the carrier ruling, MD-18) · `agent front door` · `evaluation sink` / `sdp q` · `demand map` · `recipe` (the growth-valve sense) — the last four ratified at the phase-5 PR review (the front-door ruling, MD-22); the - **Surfaces & projections** rows above give the referents. + **Surfaces & projections** rows above give the referents · `constant form` · `comment form` · + `references` · `role` · `layer` · `context` — the six of the annotations arc (MD-36 to + MD-38; all three rulings state `ready`). - **Descriptor values locked:** `kind` ∈ {`behavior`,`workflow`,`example`,`rule`,`constraint`,`model`, `decision`,`contract`} · `altitude` ∈ {`epic`,`feature`,`story`} · `readiness` ∈ {`idea`,`scoped`,`defined`,`ready`}. @@ -259,11 +270,12 @@ delivery fact, workflow state, or graph state. as descriptors (labels / projections, not descriptors) · `exemplifies` (relation) · `status` (on the `decision` section — FSM imprint; adoption arc = `readiness`, replacement = `supersedes`, rejected paths = `alternatives`/`consequences` — MD-11) · `pattern` (as an architectural primitive, kind, or - `pattern:` namespace — ordinary English remains; MD-34). + `pattern:` namespace — ordinary English remains; the architectural-annotation ruling, MD-38, + superseding MD-34) · `annotation` (→ `anchor`; the verb "annotate" survives as "write an anchor"). - **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) — a layer checks never live in: + one validation path (the authored level before the extractor runs) — a level checks never live in: validators consume the one graph only, never a second validation path (one validation path, MD-14). - **Resolved (MD-15):** Markdown Spec files use the **`.sdp.md`** extension by default; **`.sdp.ts`** identifies the lawful TypeScript carrier without colliding with every JS test-runner's default glob. @@ -283,5 +295,5 @@ delivery fact, workflow state, or graph state. - **Naming (resolved — the protocol naming, MD-5):** product **Libar Software Delivery Protocol** (short form "the Protocol"); CLI **`sdp`**; npm **`@libar-dev/software-delivery-protocol`** (single package); repo `libar-dev/software-delivery-protocol`; namespaces `@libar-dev/` (OSS) vs `@libar-ai/` (commercial). - "Protocol" names the meta-layer (a conformance contract, not a workflow); "process" is retained only for + "Protocol" names the meta-level (a conformance contract, not a workflow); "process" is retained only for the modeled activity. diff --git a/README.md b/README.md index 3365f576..7bf6d0c8 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ pnpm --silent sdp:q 'return g.specs().length' ``` The `sdp:q` script supplies this repository's required fixture exclusions. Use the -[twenty-six graph-first recipes](docs/agent-surface/recipes.md) for backlog, drift, verifier, impact, +[twenty-eight graph-first recipes](docs/agent-surface/recipes.md) for backlog, drift, verifier, impact, Pack, readiness, promotion, open-question, dependency, mention, and entry-search queries. The [architecture walkthrough](docs/agent-surface/architecture.md) follows shared policy, @@ -147,4 +147,4 @@ for carrier support. The package installs three agent on-ramps as `SKILL.md` files under `node_modules/@libar-dev/software-delivery-protocol/.agents/skills/`. Use `sdp-agent-surface` to read the graph, `sdp-authoring` to author intent, and `sdp-sessions` for advisory delivery-session -routing. The same package includes the twenty-six recipe bodies at `docs/agent-surface/recipes.md`. +routing. The same package includes the twenty-eight recipe bodies at `docs/agent-surface/recipes.md`. diff --git a/check-prose-schema.mjs b/check-prose-schema.mjs index c54a3c36..56b5822d 100644 --- a/check-prose-schema.mjs +++ b/check-prose-schema.mjs @@ -23,7 +23,7 @@ for (const section of [ ]) { expectContains("src/model/sections.ts", section, "missing section description owner"); } -expectContains("src/graph/schema.ts", 'schemaVersion = "0.7.0"', "missing 0.7.0 schema literal"); +expectContains("src/graph/schema.ts", 'schemaVersion = "0.8.0"', "missing 0.8.0 schema literal"); expectContains("src/graph/schema.ts", "readonly narrative?: string;", "missing graph narrative"); expectContains("src/reader/reader.ts", "readonly narrative?: string;", "missing Reader narrative"); expectContains( @@ -47,7 +47,7 @@ expectContains( ); expectContains( "specs/extraction/schema-versioning.declared-version.sdp.md", - '{schemaVersion: "0.7.0"}', + '{schemaVersion: "0.8.0"}', "missing declared-version example literal", ); expectContains( @@ -62,7 +62,7 @@ expectContains( ); expectContains( "docs/concept/06-consumers-and-projections.md", - "Design Review now renders `Spec.narrative` and the seven approved section descriptions from those Reader/graph values (schema `0.7.0`), with no source reparse and stable omission when prose is absent.", + "Design Review now renders `Spec.narrative` and the seven approved section descriptions from those Reader/graph values (schema `0.8.0`), with no source reparse and stable omission when prose is absent.", "missing landed prose projection claim", ); diff --git a/docs/agent-surface/recipes.md b/docs/agent-surface/recipes.md index b2e0bf1f..603c5a47 100644 --- a/docs/agent-surface/recipes.md +++ b/docs/agent-surface/recipes.md @@ -32,11 +32,12 @@ pnpm exec sdp q '' --root PATH --exclude PATH --exclude PATH `--root PATH` picks the extraction root (default: the working directory) and `--exclude` is repeatable for root-relative path prefixes. `PATH` is a placeholder, not a literal directory. -**Some recipes open with a parameter.** Recipes 3, 6, 9, 14, 19, 21, 22, 23, and 25 take their -subject on the opening `const` line(s): a Spec id, a search term, a component id, a list of -Spec ids, or a list of entry addresses. Those lines name *this* repository's corpus so every body -runs as written here (the recipe check executes each one verbatim); in your own corpus, -substitute your subject on that line before running. A Spec id or component id absent from the graph returns `{ found: false }` rather than failing. +**Some recipes open with a parameter.** Recipes 3, 6, 9, 14, 19, 21, 22, 23, 25, and 27 take +their subject on the opening `const` line(s): a Spec id, a search term, a component id, a list of +Spec ids, a list of entry addresses, or a Pack id. Those lines name *this* repository's corpus so +every body runs as written here (the recipe check executes each one verbatim); in your own corpus, +substitute your subject on that line before running. A Spec id, component id, or Pack id absent +from the graph returns `found: false` rather than failing. **Recipe 4 is different.** Recipe 4 filenames travel via `SDP_CHANGED_FILES_JSON`; callers never substitute filenames into the JavaScript fence. Keep its query body static and pass changed paths @@ -225,7 +226,7 @@ The caller acquires the changed paths and passes them as data, never as query so ```sh SDP_CHANGED_FILES_JSON="$(git diff --name-only -z | node -e 'const fs = require("node:fs"); const names = fs.readFileSync(0).toString("utf8").split("\0"); process.stdout.write(JSON.stringify(names.slice(0, -1)));')" \ -pnpm --silent sdp:q 'const changed = JSON.parse(process.env.SDP_CHANGED_FILES_JSON ?? "[]"); const radius = g.blastRadius(changed); const impactReasons = (item) => ({ id: item.id, reasons: item.reasons.map((reason) => reason.throughBinding === undefined ? { file: reason.file, via: null } : { file: reason.file, via: reason.throughBinding.id, edgeType: reason.throughBinding.edgeType, claim: reason.throughBinding.claim }) }); const atRiskReasons = (item) => ({ id: item.id, nodeType: item.nodeType, reasons: item.reasons.map((reason) => ({ from: reason.from, edgeType: reason.edgeType, to: reason.to, claim: reason.claim })) }); return { changedFiles: radius.changedFiles, impactedSpecs: radius.impactedSpecs.map(impactReasons), atRiskSpecs: radius.atRisk.filter((item) => item.nodeType === "Primitive").map(atRiskReasons), atRiskOther: radius.atRisk.filter((item) => item.nodeType !== "Primitive").map(atRiskReasons), coverageUnknownFiles: radius.coverageUnknown };' --json +pnpm --silent sdp:q 'const changed = JSON.parse(process.env.SDP_CHANGED_FILES_JSON ?? "[]"); const radius = g.blastRadius(changed); const impactReasons = (item) => ({ id: item.id, reasons: item.reasons.map((reason) => reason.throughBinding === undefined ? { file: reason.file, via: null } : { file: reason.file, via: reason.throughBinding.id, edgeType: reason.throughBinding.edgeType, claim: reason.throughBinding.claim }) }); const atRiskReasons = (item) => ({ id: item.id, nodeType: item.nodeType, reasons: item.reasons.map((reason) => ({ from: reason.from, edgeType: reason.edgeType, to: reason.to, claim: reason.claim })) }); return { changedFiles: radius.changedFiles, impactedSpecs: radius.impactedSpecs.map(impactReasons), atRiskSpecs: radius.atRisk.filter((item) => item.nodeType === "Primitive").map(atRiskReasons), atRiskOther: radius.atRisk.filter((item) => item.nodeType !== "Primitive").map(atRiskReasons), coverageUnknownFiles: radius.coverageUnknown, unlinkedUnits: radius.unlinked.map((unit) => ({ id: unit.id, file: unit.file })) };' --json ``` The query body is static and reads only the JSON environment value; neither the shell nor the reader reevaluates filenames. `JSON.parse` receives data, so quotes, shell metacharacters, spaces, Unicode, and embedded newlines remain filenames rather than JavaScript or shell syntax. The reader never shells to git. @@ -235,10 +236,10 @@ const changed = JSON.parse(process.env.SDP_CHANGED_FILES_JSON ?? "[]"); const radius = g.blastRadius(changed); const impactReasons = (item) => ({ id: item.id, reasons: item.reasons.map((reason) => reason.throughBinding === undefined ? { file: reason.file, via: null } : { file: reason.file, via: reason.throughBinding.id, edgeType: reason.throughBinding.edgeType, claim: reason.throughBinding.claim }) }); const atRiskReasons = (item) => ({ id: item.id, nodeType: item.nodeType, reasons: item.reasons.map((reason) => ({ from: reason.from, edgeType: reason.edgeType, to: reason.to, claim: reason.claim })) }); -return { changedFiles: radius.changedFiles, impactedSpecs: radius.impactedSpecs.map(impactReasons), atRiskSpecs: radius.atRisk.filter((item) => item.nodeType === "Primitive").map(atRiskReasons), atRiskOther: radius.atRisk.filter((item) => item.nodeType !== "Primitive").map(atRiskReasons), coverageUnknownFiles: radius.coverageUnknown }; +return { changedFiles: radius.changedFiles, impactedSpecs: radius.impactedSpecs.map(impactReasons), atRiskSpecs: radius.atRisk.filter((item) => item.nodeType === "Primitive").map(atRiskReasons), atRiskOther: radius.atRisk.filter((item) => item.nodeType !== "Primitive").map(atRiskReasons), coverageUnknownFiles: radius.coverageUnknown, unlinkedUnits: radius.unlinked.map((unit) => ({ id: unit.id, file: unit.file })) }; ``` -Every result class is returned. **Impacted Specs** are authored-at or bound-to a changed file. **At-risk Specs** are the one-hop Primitive neighbors; **atRiskOther** retains every other at-risk node and its `nodeType`. Each at-risk reason carries its connecting edge and claim, while **coverageUnknownFiles** names changed files the graph records nothing at. File-level reach never claims exhaustive symbol-level reach — that would ride the impact graph, which does not exist. +Every result class is returned. **Impacted Specs** are authored-at or bound-to a changed file. **At-risk Specs** are the one-hop Primitive neighbors; **atRiskOther** retains every other at-risk node and its `nodeType`. Each at-risk reason carries its connecting edge and claim, while **coverageUnknownFiles** names changed files the graph records nothing at. **unlinkedUnits** names each changed code unit that binds no Spec, such as an identity-only anchor, with its file; it is recorded, so its file is not coverage-unknown, and it implies no coverage. File-level reach never claims exhaustive symbol-level reach — that would ride the impact graph, which does not exist. ## 5. The Pack review backbone @@ -1791,3 +1792,128 @@ rest only on themselves, and `totals.specsInCycles` every member of either. The never refuses: a cycle is data about the authored dependencies, and the readiness floor reads each `dependsOn` target's stated rung without walking a chain. The law is `spec:consumers.agent-surface.address-and-cycle-recipes`. + +## 27. References into a design + +*When you need this: you are reviewing a Pack and want to see, for each member, which code +answers to it, which code realizes it, whether a verifier is bound, and which built Specs it +builds on, with no fact read as another.* + +The opening `const id` is the parameter. Replace it with the Pack you are reviewing; an unknown +Pack returns `{ id, found: false }`. + +```js +const id = "pack:spec-studio-v1"; +const pack = g.packContext(id); + +if (pack === undefined) { + return { id, found: false }; +} + +const unitsOf = (bindings) => bindings.map((unit) => ({ id: unit.codeId, file: unit.file ?? null })); +const rows = pack.members.map((member) => { + const context = g.specContext(member.id); + const buildsOn = (context?.relationsOut ?? []) + .filter((relation) => relation.type === "dependsOn" || relation.type === "refines") + .map((relation) => ({ relation, target: g.specContext(relation.otherId) })) + .filter(({ target }) => (target?.deliveryFacts ?? []).includes("implemented")) + .map(({ relation, target }) => ({ + id: relation.otherId, + via: relation.type, + implementedBy: unitsOf(target.implementations), + })); + return { + id: member.id, + resolved: context !== undefined, + referencedBy: unitsOf(context?.references ?? []), + implementedBy: (context?.implementations ?? []).map((unit) => unit.codeId), + hasVerifier: (context?.deliveryFacts ?? []).includes("has-verifier"), + buildsOn, + }; +}); +const count = (test) => rows.filter(test).length; + +return { + found: true, + id, + totals: { + members: rows.length, + withReferences: count((row) => row.referencedBy.length > 0), + withImplementations: count((row) => row.implementedBy.length > 0), + withVerifier: count((row) => row.hasVerifier), + withBuildsOn: count((row) => row.buildsOn.length > 0), + unbound: count( + (row) => row.referencedBy.length === 0 && row.implementedBy.length === 0 && !row.hasVerifier, + ), + }, + rows, +}; +``` + +Each row puts four independent facts side by side. `referencedBy` lists the code units whose +anchors name the member in `references`, with the file each sits in: that code answers to the +design without claiming to realize it, and the edge confers nothing. `implementedBy` lists the +units whose `satisfies` resolves to the member, the edge behind `implemented`. `hasVerifier` is +the derived `has-verifier` fact: a resolving verifier binding exists, not that it passed. +`buildsOn` lists the Specs the member `dependsOn` or `refines` that carry `implemented`, each with +the relation it comes through and the units that realize it, with their files. It is derived from +the member's own relations and the targets' bindings: a design that builds on existing code says +so on its own side, so the dependency has one home, and the column confers nothing on the member. + +An empty `referencedBy` or `implementedBy`, or a `false`, reads as unbound. The graph records no +binding there, which says nothing about whether the code is built. An empty `buildsOn` says only +that the member names no implemented Spec through `dependsOn` or `refines`. The four facts form no +ladder: a member can be referenced and verified while nothing satisfies it, and a member that +builds on implemented code is not itself implemented, so never read `referencedBy` or `buildsOn` +as progress toward `implemented`. Rows keep the manifest's member order and each `buildsOn` list +keeps relation then id order. `resolved: false` marks a member the graph does not hold, and +`totals.unbound` counts the members with none of the first three facts. The law is +`spec:decisions.anchor-binding-grain`. + +## 28. Roles, layers and contexts + +*When you need this: you want the architecture vocabulary the code anchors actually state, every +role, layer, and context value with the units that carry it, before you name a new one or +reconcile two spellings of one.* + +```js +const units = graph.nodes.filter((node) => node.nodeType === "CodeNode"); +const taxonomy = (field) => { + const byValue = new Map(); + for (const unit of units) { + const value = unit[field]; + if (typeof value !== "string") continue; + const bucket = byValue.get(value); + if (bucket === undefined) byValue.set(value, [unit.id]); + else bucket.push(unit.id); + } + return [...byValue.keys()].sort().map((value) => ({ value, units: byValue.get(value).sort() })); +}; +const references = graph.edges.filter((edge) => edge.type === "references"); + +return { + totals: { + codeUnits: units.length, + withRole: units.filter((unit) => typeof unit.role === "string").length, + withLayer: units.filter((unit) => typeof unit.layer === "string").length, + withContext: units.filter((unit) => typeof unit.context === "string").length, + references: references.length, + referencingUnits: new Set(references.map((edge) => edge.from)).size, + referencedSpecs: new Set(references.map((edge) => edge.to)).size, + }, + roles: taxonomy("role"), + layers: taxonomy("layer"), + contexts: taxonomy("context"), +}; +``` + +The body takes no parameter. The taxonomy reads the CodeNodes alone, so a value appears only when +an anchor states it, and an unlabelled unit counts in `totals.codeUnits` and in no taxonomy row; +omission is lawful. Each value lists its units in id order, so an owner can find the anchors to +edit when two spellings name one category. A role or a context is a free value in one lowercase +kebab token, checked against no list; a layer is one of `edge`, `application`, `domain`, `adapter`, +or `infrastructure`, and only a `component:` anchor carries a layer or a context. The `references` +counts are edges, kept apart from units: one unit that references three Specs is three edges and +one referencing unit. None of these attributes or edges confers a delivery fact or moves a +readiness floor. The census renders the same taxonomy for a human reader. The law is +`spec:decisions.architectural-annotation`. diff --git a/docs/concept/06-consumers-and-projections.md b/docs/concept/06-consumers-and-projections.md index ce2605bf..96c17430 100644 --- a/docs/concept/06-consumers-and-projections.md +++ b/docs/concept/06-consumers-and-projections.md @@ -145,12 +145,14 @@ Why patching dissolves: The flagship human view is the Design Review's relationship slice: a derived, regenerable human-readable projection — **fully derived** and reproducible (delete and rebuild identically). Per spec it shows: header (title, `kind`, `altitude`, `readiness`, and any stated-vs-derived divergence); intent and behaviour (rules/examples); relations; bindings (implementing code, tests, with source links, derived from anchors); verification status (does a linked, enabled verifier *exist* — the `has-verifier` delivery fact, not run results); an impact list; and `claim` cues (declared vs anchored vs inferred shown distinguishably, P9). The census, Mermaid, and Gherkin-shaped read projections are co-equal derived views with narrower jobs; none re-specifies the Design Review. -Owned prose is already available through the graph and Reader: `narrative` appears in a spec summary/context and concept search, while section descriptions remain in `SpecContext.sections` and are searchable. Design Review now renders `Spec.narrative` and the seven approved section descriptions from those Reader/graph values (schema `0.7.0`), with no source reparse and stable omission when prose is absent. +Owned prose is already available through the graph and Reader: `narrative` appears in a spec summary/context and concept search, while section descriptions remain in `SpecContext.sections` and are searchable. Design Review now renders `Spec.narrative` and the seven approved section descriptions from those Reader/graph values (schema `0.8.0`), with no source reparse and stable omission when prose is absent. Schema `0.6.0` changes accepted ids by reserving the `#` sub-part for an entry address and changes ordering semantics by preserving authored key order in `design`, `ui`, and `model.terms`, with a `design` or `ui` section's `description` first. Field shapes are unchanged. Schema `0.7.0` adds a Pack node's ordered `members` list and an open question's optional `key`, and admits `question` as an entry-address section. Node and edge order are unchanged. +Schema `0.8.0` adds the anchored `references` edge, one `satisfies` or `verifies` edge per target, and a code unit's optional `role`, `layer`, and `context`. + **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/DECISIONS.md b/docs/concept/DECISIONS.md index be9340e8..a59380da 100644 --- a/docs/concept/DECISIONS.md +++ b/docs/concept/DECISIONS.md @@ -4,7 +4,7 @@ ## The ratified-name registry -In prose, lead with meaning and use a code only as a parenthetical lookup key. Curation records the ADR three-part test: hard to reverse, surprising without context, and a real trade-off. +In prose, lead with meaning and use a code only as a parenthetical lookup key. Curation records the ADR three-part test: hard to reverse, surprising without context, and a real trade-off. `held` marks a decision Spec that states `defined` and awaits the owner's `ready`; `superseded` marks a ruling a later entry replaces, and the row points forward. Numbering is append-only: absent identifiers such as MD-3 and MD-6 are retired or reserved historical positions and are never reused. @@ -38,12 +38,15 @@ positions and are never reused. | MD-27 | the Gherkin carrier option | durable | Behavior and example Spec IDs may select a graph-aware Gherkin carrier; Markdown stays default, execution stays behind generated contracts and anchored handlers, and no lifecycle taxonomy is imported. MD-28 settles the canonical suffix as `.sdp.gherkin`. | [Spec](../../specs/decisions/gherkin-carrier-option.sdp.md) (`spec:decisions.gherkin-carrier-option`) | | MD-28 | the `.sdp.gherkin` extension | durable | Canonical Gherkin carriers use `.sdp.gherkin`; bare `.feature` stays non-canonical import-source territory so Protocol files stay outside default Cucumber globs. | [Spec](../../specs/decisions/sdp-gherkin-extension.sdp.md) (`spec:decisions.sdp-gherkin-extension`) | | MD-29 | the carrier universality bound | durable | Gherkin remains honest for behavior and example Specs; universality is per-ID carriers plus a generated read projection, with Markdown still the default. | [Spec](../../specs/decisions/carrier-universality.sdp.md) (`spec:decisions.carrier-universality`) | -| MD-30 | structural anchors confer nothing | durable | `component` and `uses` author narrow CodeNode structure while leaving realization, readiness, and delivery facts untouched. | [Spec](../../specs/decisions/structural-anchor-semantics.sdp.md) (`spec:decisions.structural-anchor-semantics`) | +| MD-30 | structural anchors confer nothing | superseded by MD-38 | `component` and `uses` author narrow CodeNode structure while leaving realization, readiness, and delivery facts untouched; the semantics survive inside MD-38. | [Spec](../../specs/decisions/structural-anchor-semantics.sdp.md) (`spec:decisions.structural-anchor-semantics`) | | MD-31 | adopted registrars are committed | durable | A registrar imported by tracked authored code is committed and byte-checked; unadopted registrar siblings remain ignored and regenerable. | [Spec](../../specs/decisions/adopted-registrars-committed.sdp.md) (`spec:decisions.adopted-registrars-committed`) | | MD-32 | the shipped projections stay frozen | durable | The shipped Design Review, census, Mermaid, and Gherkin projections are settled read models; re-specifying them is refused, and reopening needs a superseding decision that passes the ADR test. | [Spec](../../specs/decisions/shipped-projections-frozen.sdp.md) (`spec:decisions.shipped-projections-frozen`) | | MD-33 | planning truths live in ruled graph homes | durable | Each planning-truth type has one ruled home in the graph, the briefs index shape is retired as a carrier of law, and reopening needs a superseding decision that passes the ADR test. | [Spec](../../specs/decisions/planning-truths-placement.sdp.md) (`spec:decisions.planning-truths-placement`) | -| MD-34 | architectural significance rides existing primitives | durable | Specs carrying architectural significance are linked by the existing relations, code linkage rides the satisfies → decidedBy join, and grouping is derived — no pattern vocabulary is admitted. | [Spec](../../specs/decisions/architectural-significance-rides-primitives.sdp.md) (`spec:decisions.architectural-significance-rides-primitives`) | -| MD-35 | source commentary never enters the graph | durable | JSDoc and doc comments author no graph content; the statically reified anchor constant is the only write path from code, and law-carrying comments promote into Specs. | [Spec](../../specs/decisions/jsdoc-graph-extraction-refused.sdp.md) (`spec:decisions.jsdoc-graph-extraction-refused`) | +| MD-34 | architectural significance rides existing primitives | superseded by MD-38 | Specs carrying architectural significance are linked by the existing relations and no pattern vocabulary is admitted; the CodeNode-grain role it gave up returns under MD-38. | [Spec](../../specs/decisions/architectural-significance-rides-primitives.sdp.md) (`spec:decisions.architectural-significance-rides-primitives`) | +| MD-35 | source commentary never enters the graph | superseded by MD-36 | Comment prose authors no graph content and law-carrying comments promote into Specs; the identity half, refusing reserved tags in a doc comment, is reversed by MD-36. | [Spec](../../specs/decisions/jsdoc-graph-extraction-refused.sdp.md) (`spec:decisions.jsdoc-graph-extraction-refused`) | +| MD-36 | the comment form | durable | A `/** … */` comment carrying reserved camelCase `@sdp*` tags is a second extracted representation of the anchor, feeding the same closed envelope as the constant form; prose still authors nothing, no import is required, and the package ships a zero-dependency `/anchors` subpath. | [Spec](../../specs/decisions/anchor-comment-form.sdp.md) (`spec:decisions.anchor-comment-form`) | +| MD-37 | bindings are optional, plural, and may reference a design | durable | `satisfies` is optional and plural, `verifies` plural, targets name whole Specs, an identity-only code anchor is lawful, and the anchored `references` edge says code answers to a design it does not claim to realize, conferring no delivery fact; a design that builds on existing code says so by its own relation, never by a reference from the code. | [Spec](../../specs/decisions/anchor-binding-grain.sdp.md) (`spec:decisions.anchor-binding-grain`) | +| MD-38 | architectural significance is annotated where it is realized | durable | A code anchor may carry a `role`, a `component:` anchor its closed-set `layer` and its `context`, and the anchor is the declared component; the `component`/`uses` semantics, structural non-conferral, no inference, no status on code, and no pattern vocabulary survive from MD-30 and MD-34. | [Spec](../../specs/decisions/architectural-annotation.sdp.md) (`spec:decisions.architectural-annotation`) | ### Current executable decision-spec pointers diff --git a/package.json b/package.json index f9a5c3be..e14e31dc 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,10 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, + "./anchors": { + "types": "./dist/anchors.d.ts", + "import": "./dist/anchors.js" + }, "./runner": { "types": "./dist/runner/index.d.ts", "import": "./dist/runner/index.js" diff --git a/plans/40-annotations-closer-to-v0.md b/plans/40-annotations-closer-to-v0.md new file mode 100644 index 00000000..e4ffc655 --- /dev/null +++ b/plans/40-annotations-closer-to-v0.md @@ -0,0 +1,382 @@ +# Plan 40. Annotations, closer to the original design + +> **Status:** ✅ EXECUTED. All three rulings state `ready` since 2026-10-10: the owner stated +> `ready` on MD-36 and MD-38, then on MD-37 once the rulings of 2026-10-10 (the last section) +> closed the open questions the three decisions carried. The full gate passed at the wave 2 +> close on both repositories and runs again on the change that lands those rulings; every count +> below is derived, re-run the recipes rather than quoting it. The owner's direction of +> 2026-10-09: move closer to the v0 design and bring back the value of annotating +> architecturally significant units and their relationships in code. This plan is the contract every session and agent on the arc reads. The engine, +> corpus, and skills change together on `feature/annotations-v0`, stacked on +> `feature/adopter-round-2` (PR 28). The first adopter showcases the result on its PR 12 branch. +> Readiness and backlog are read from the graph; this file is the brief, not the law. Revised +> once after an independent design review (gpt-6-astra, read-only) before wave 2. + +## Why this arc + +Three inputs, read on 2026-10-09: + +- `docs/lineage/annotation-scout-2026-10-04.md`: gen 1's annotation model, what actually drifted + there (duplicate identities, scan scope, syntax; not an open vocabulary and not dual + extraction), and the v0 mapping. It corrects the context lines of MD-30, MD-34 and MD-35. +- `docs/lineage/v0-design/` chapters 01 to 04 and 06: a zero-dependency marker package (02 §3), + three marker forms feeding one normalized envelope (04 §2, 06 §3), optional plural `satisfies` + (04 §2.4), a `#` sub-segment in the id grammar (03 §6.2), a declared Component with `layer` + and `bounded_context` (03 §2.5), three edge provenances (03 §4), and the line v0 drew: markers + carry identity and structure, never readiness, intent, behavior or verification. +- The first adopter's PR 12 (`libar-dev/libar-platform`): every one of its 40 code anchors sits + in a test or the harness because its runtime code cannot import this package; `byFile` on a + source file returns nothing; `design/tools/anchor-sites.json` is a hand-kept join; Specs were + split so an anchor could claim only what the code does; a stub reads as `implemented`; a + relationship the design states in prose cannot reach a page. + +## The rulings this arc makes + +Each is a decision Spec, and all three state `ready` (the owner, 2026-10-10). Names lead; numbers +follow in `docs/concept/DECISIONS.md`. The rulings of 2026-10-10 at the end of this plan are +numbered on their own. + +### R1. The comment form (`spec:decisions.anchor-comment-form`, MD-36) + +Supersedes `spec:decisions.jsdoc-graph-extraction-refused` (MD-35). A second extracted +representation of the anchor: a `/** … */` comment carrying reserved camelCase `@sdp*` tags (TSDoc-compatible), read from every top-level `/** */` block +of a `.ts`/`.tsx` file. It feeds the same +closed anchor envelope as the constant form. Prose in the comment still authors nothing; only +the reserved lines are read. The same id in two forms is a duplicate id. One grammar, by example: + +```ts +/** + * The online rebuild path. Explanation stays local commentary and confers nothing. + * + * @sdpAnchor impl:platform.read-model.rebuild + * @sdpLabel the online rebuild path + * @sdpSatisfies spec:application.rebuild + * @sdpReferences spec:application.history-rebuild + * @sdpComponent component:platform.read-model + * @sdpUses impl:platform.gate, impl:platform.context.adapter + * @sdpRole service + */ +export async function rebuild(...) {} +``` + +- `@sdpAnchor ` opens the anchor; the id's namespace selects the flavor exactly as the + constant builders do: `impl:` `api:` `component:` are code anchors, `test:` is a test anchor, + `oracle:` is an oracle anchor. +- Target lines by flavor: `@sdpSatisfies` (code), `@sdpVerifies` (test), `@sdpModels` + (oracle). Lists are comma-separated. +- Structural lines, code anchors only: `@sdpComponent`, `@sdpUses`, `@sdpReferences`, + `@sdpRole`; component anchors additionally `@sdpLayer`, `@sdpContext`. +- `@sdpLabel` on any flavor. +- Any other `@sdp*` tag is an envelope error (closed envelope, as the constant form). + A block with reserved tags and no `@sdpAnchor`, or with two, is an envelope error. The block's + first line is the binding's `file`/`line`. +- Attachment: every top-level `/** */` block containing a reserved tag is one anchor, whether it + leads an import, a declaration, trails the last statement, or is the only content of the file. + Attachment to a declaration is not recorded: the file is the binding grain of both forms, and + no attachment kind or exported name (v0 03 §2.7) is kept (rulings of 2026-10-10, R4). A + reserved tag in a nested position is a misplaced-tag error. +- Cardinality: each tag at most once per block; `@sdpModels` one target; the list tags take + comma lists with no empty or repeated item; a non-empty line after the first reserved tag + that does not start with `@` is a refused continuation, so a wrapped target never becomes + ignored prose; prose precedes the tags. +- `@sdpRole` and `@sdpContext` values are one lowercase kebab token (`^[a-z][a-z0-9-]*$`), never + normalized; `@sdpLayer` is the closed five-value set. +- Parsing reads the raw comment text with a line grammar; it does not depend on the TypeScript + JSDoc tag parser. A comment that does not open with `/**` is never read. +- No import is required. Trust is by reserved grammar, not by builder import: a file is read + for the comment form when it contains the `@sdp` prefix. The constant form keeps the + import-based prefilter and the "untrusted builder mints nothing" rule; the two routes are + separate. Neither is a security boundary against someone who can edit source; the + extraction root and exclusions bound the scan, as they do today. + +Also under R1: the package ships a zero-dependency subpath `@libar-dev/software-delivery-protocol/anchors` +exporting the id builders and the three anchor builders and nothing else, for runtimes that may +import but must not load `node:*` or ts-morph (v0 02 §3 verbatim). ESM with declarations, built +from the leaf modules; a test asserts the built file's import closure. The extractor trusts that +specifier exactly, and the source barrel as a relative trusted module. + +### R2. Binding grain (`spec:decisions.anchor-binding-grain`, MD-37) + +Refines `spec:model.anchors`. + +- `satisfies` is optional and plural on a code anchor; `verifies` is plural on a test anchor + (non-empty). The constant form accepts one `ref(…)` or a fresh array literal of them. Each + resolving target confers its fact as today; `implemented` stays whole-Spec. +- A new anchored edge `references`: CodeNode → Spec, from `references?: readonly SpecId[]` on a + code anchor. It confers no delivery fact, moves no floor, and the drift alarm ignores it. It + says this code answers to that design without claiming to realize it; a design that builds on + existing code states that by its own relation (rulings of 2026-10-10, R1). A target also named + in `satisfies` is an error. +- An identity-only code anchor (no `satisfies`, no `references`) is lawful: it mints a CodeNode + for structure and for `byFile`, and nothing else. `byFile` may return nodes with an empty Spec + list; `blastRadius` reports changed CodeNodes with no Spec linkage as their own list, never + dropped and never implying coverage. +- `references` may target any Spec, decisions included. `satisfies` to a decision stays the + authoring prohibition of MD-26, not a new conferral filter. The drift alarm ignores + `references` and still reports a separate `satisfies` target below `ready`; recipe 1's + predicate is unchanged. +- Anchor targets name whole Specs; an entry-address target (`spec:x#design.key`, v0 03 §6.2) is + refused (rulings of 2026-10-10, R2). + +### R3. Architectural annotation (`spec:decisions.architectural-annotation`, MD-38) + +Supersedes `spec:decisions.structural-anchor-semantics` (MD-30) and +`spec:decisions.architectural-significance-rides-primitives` (MD-34). Every operational rule of +both that still holds is restated inside: `component` and `uses` semantics (one-level +membership, unique non-empty targets, self-reference refused, cycles as data, whole-envelope +refusal, warn-level optional anchor lint), structural non-conferral, no inference from imports, +no status or readiness on code, significance never selects a Spec kind, no `pattern:` namespace, +`dependsOn` for genuine need only and scheduling edges refused, negative constraints as declared +intent, no anchor pointed at an unfinished Spec to manufacture coverage. What changes: + +- `role?: string` on any code anchor: the architectural pattern the unit plays (gen 1's role; + `service`, `decider`, `projection`, `read-model`, `codec`, `contract`, `barrel`, `utility` are + the lineage set, not a closed list). A free, corpus-owned vocabulary; the census renders its + taxonomy with counts from the graph; no validator checks it against a list. A corpus-declared + role vocabulary checked by a validator is refused (rulings of 2026-10-10, R3). +- `layer?: "edge" | "application" | "domain" | "adapter" | "infrastructure"` and + `context?: string` (bounded context) on `component:` anchors only (v0 03 §2.5). `layer` is a + closed set; a value outside it is an envelope error. +- The `component:` anchor is anchored architecture, not v0's declared Component that could exist + before code. An unrealized component lives in Specs, never as an invented CodeNode. "No + independently declared Component" is a retained limitation, named in the decision. A package + may hold several layers or contexts; the mapping is reviewed, never one component per package. +- The five layers, one line each, live in the decision; omission is lawful and nothing forces a + label to populate the census. The census links each role, layer and context value to its + units so an owner can reconcile synonyms. +- The census and the Design Review render the new structure under their existing contracts + (MD-30 already gave the census that duty); Mermaid and Gherkin are untouched, which keeps + MD-32 as it stands. The decision names this. +- Guidance from gen 1's ownership doc, restated in `sdp-authoring`: annotations are curated, + never a coverage quota; an identity has one owner; a realizing unit documents its local how, + never a paraphrase of the Spec's what or why. + +### What stays refused + +`status`, readiness, intent, behavior or verification on code (v0 04 §2.4). Inference from +imports as anchored or declared structure. A Spec-side list of its own code bindings. A second +graph. Promotion of anything inferred without a human edit. + +## The engine contract + +Schema `0.8.0`. The closed edge list becomes twelve: the eleven of today plus `references` +(derived, anchored). `CodeNode` gains optional `role`, `layer`, `context`. `satisfies` and +`verifies` edges are one per target. Delivery facts and readiness floors are unchanged. + +Validators: `references` and plural `satisfies` targets resolve through the existing referential +check; non-resolving targets confer nothing and report as today. The structural validator adds: +`references` non-empty and unique when present; a target in both `satisfies` and `references` +is an error; `layer` outside the closed set is an envelope error; `layer`/`context` on a non- +component anchor is an envelope error. Duplicate ids across forms report through the existing +duplicate-id validator. + +Reader: `SpecContext` gains `references` (the CodeNodes that reference the Spec, with file and +line). `byFile` returns the comment-form anchors at their site and may return nodes with no Spec. +`blastRadius` traverses `references` as a binding, naming the edge type and claim in the reason, +and lists changed unlinked CodeNodes separately. The frozen entry adapters stay +three. + +Projections: the census structural section renders role, layer, context and `references`; the +Design Review Spec page lists "Referenced by" and the component rows show layer and context. +Mermaid and Gherkin untouched. + +Recipes: 27 "References into a design" (per Spec, independent facts side by side: the +referencing units, the realizing units, whether an enabled verifier exists, and the derived +"builds on" column of the rulings of 2026-10-10, R1; absence reads as +unbound, never as "not built"; the claim-map projection, without a ladder), 28 "Roles and layers" (the +taxonomy from the graph). Both executed by `test/recipes.test.ts`. + +Skills and docs: `sdp-agent-surface` (twelve edges, the new node fields, recipes 27 and 28), +`sdp-authoring` (both forms written out, the subpath import, plural targets, `references`, role, +layer, context, the curation guidance), `docs/agent-surface/recipes.md`, `CONTEXT.md` (terms +below), `docs/concept/DECISIONS.md` (MD-36 to MD-38; MD-30, MD-34, MD-35 marked superseded), +`AGENTS.md` one line on the comment form. + +Vocabulary for `CONTEXT.md`: **constant form** and **comment form** as the two representations +of an anchor; **references** as the non-conferring edge; **role**, **layer**, **context** as +the structural attributes. `annotation` and `marker` stay rejected synonyms of `anchor`; the +verb "annotate" in prose means "write an anchor". + +## Self-binding showcase in this repository + +The engine's own reader and projections code carries comment-form anchors, and its units +reference the decisions they follow. The references that once pointed existing code at +`pack:spec-studio-v1` members whose design builds on it came out under the rulings of 2026-10-10 +(R1): the Studio Specs already state that dependency through their own relations. Every engine +component declares `layer` and `context`; architecturally significant units carry a `role`. +Recipe 27 over the Studio Pack is the claim map as a projection of Spec relations and bindings, +with its "builds on" column. The self-hosting oracle's model changes with it: expected nodes +separate from expected edges, zero-to-many bindings per anchor, the owner-reviewed +significant-unit set preserved rather than generated, comment attachment asserted on its own; +the census counts units apart from edges. Pins are re-derived at close and labelled so. + +## The adopter showcase (PR 12 branch) + +Re-pin the package to this branch. Move the test-resident implementation anchors into `src/` as +comment-form anchors at their real sites: one identity per realizing unit, so an identity that +stood for several files becomes several identities, each choosing `satisfies` or `references` +honestly. Keep only honest bindings and re-measure the file-to-Spec pairs they yield; the +sidecar is historical evidence, never an acceptance list (R10). Then delete +`design/tools/anchor-sites.json`, its test, and the Pack page's reading of it. Declare `component:` anchors with layer and +context from a reviewed mapping, and `uses` between them. Give significant units a role. Add `references` only where a +unit answers to a design it does not wholly realize; the rulings of 2026-10-10 (R1, R2, R10) set +the per-Spec pass. Let the Pack page and +`check.py` read the new structure. Record what changed in `docs/feedback/sdp-feedback-01.md` +and in the PR description. + +## Order and commits + +1. Corpus (decisions, model Specs, vocabulary, registry) and engine core (grammar, builders, + extractor, schema, validators, subpath) run in parallel on disjoint files. +2. Reader and projections; the engine's self-binding; then recipes, skills and docs. +3. `npm run check` green; oracle pins re-derived and labelled as re-measured. +4. The adopter showcase against a tarball packed from this branch. + +Commit per coherent unit on this branch after its focused tests pass; the full gate before the +close commit. Never push without the owner's word. + +## Acceptance, re-measured at close + +- An adopter source file with no Protocol import binds through the comment form and + `byFile` names its Spec. +- A `references` edge reaches a Spec page and recipe 27 without conferring `implemented`. +- Recipe 27 over `pack:spec-studio-v1` prints one row per member with its state. +- The census prints the role, layer and context taxonomy from the graph alone. +- The adopter's `byFile` coverage test pins the file-to-Spec pairs its honest anchors yield, + re-measured after the sidecar is gone; no pair is kept because the sidecar listed it (R10). +- `npm run check` passes; the adopter's lint, typecheck, tests and `python3 design/tools/check.py` + pass, so the comment form costs its runtime nothing. + +## Wave 2 gate close + +Pins are re-measured under plan 40. The self-binding lane's independent node and binding-edge +rosters retain the reviewed significant-unit set. The graph yields five `honesty/gaps` and nine +`conformance/prose-mentions` warnings, including the superseded architectural-significance +decision's mention of structural patterns. The oracle pins both lists. + +The close repairs the current-plan handbook and test pin, the manifest-order example's schema +version, and the README recipe count. The annotation-form graph golden is regenerated through +`projection-suite.mjs` from its materialized corpus and excluded from Prettier, because the +serializer owns its bytes. Repository projections and contracts use the generation scripts. + +The full gate runs with `npm_config_cache=/private/tmp/plan40-npm-cache`, a writable cache for +the package smoke test. Acceptance probes run after the gate to avoid concurrent writes to its +generated roots. Wave 2 closes here; the adopter work above remains in this executing arc. + +## Review + +An independent read of the branch after wave 2 (gpt-6-astra, read-only) raised seven findings. + +1. Applied. A misspelled reserved tag (`@sdp-anchor`, `@sdpAnchor:`) read as prose and vanished + without a finding. A line that opens with `@sdp` is now a reserved tag line, and a misspelling + refuses the block. +2. Applied. The nested-tag scan missed blocks before a closing brace and inside a JSX expression, + and refused nested `//` and `/*` comments the top level never reads. It now walks every comment + in the file and judges a nested comment by the top-level grammar. +3. Deferred. The engine's Studio `references` point from existing code at Specs that rest on it, + which the review reads as inverting "written against". The showcase clause and the lineage + scout read `references` as design context, so the owner rules it: the open question + `#referenceDirection` on `spec:decisions.anchor-binding-grain`. The edges stay as declared. + Ruled on 2026-10-10 (R1 below): the Studio references come out. +4. Applied. Recipe 4 dropped `blastRadius().unlinked`. Both bodies return `unlinkedUnits`, and a + test runs the recipe on a file that holds only an identity-only unit. +5. Applied. The comment-form decision now says what the contract and the parser do: two openers + are an envelope error, and an ordinary TSDoc tag after the reserved tags stays lawful and + unread. The oracle declares the amended text. +6. Applied. `CONTEXT.md` and `sdp-authoring` now teach unattached top-level blocks, one-token role + and context values, identity-only anchors under `anchored`, direct test-to-Spec + `has-verifier`, which mistakes are loud and which silent, that a block binds by file and line + so a stale one still binds, and that `validate --watch` ignores source edits. +7. Applied. `check-prose-schema.mjs` failed on the concept document's schema sentence, which now + names `0.8.0` as current and states what `0.8.0` adds. + +## Rulings of 2026-10-10 + +The owner's guidance: prefer clean solutions, and never let the amount of work decide. The +platform is the key context; orders-inventory is a supporting context and a development tool, and +nothing in the platform's core carries its specifics. Once this lands, the two branches are the +showcase for the decisions that design the rest of the platform and settle the Protocol's final +shape. Three independent advisors (gpt-6-astra, an Opus agent, a Fable agent) answered ten +questions. Each ruling notes the panel where the record names it. These rulings are numbered on +their own, apart from R1 to R3 above. + +- **R1. `references` runs one way: the code answers to the design.** A reference says the unit + follows a design or realizes part of it, so a change to the design asks the code to follow. A + design that builds on existing code states that on its own side, by `dependsOn` or `refines` to + the Spec the code satisfies, never by a reference from the code. The engine's fourteen Studio + references and the adopter's reverse references come out; units reference the decisions and + designs they follow. Recipe 27 gains a derived "builds on" column: for each member, the Specs it + `dependsOn` or `refines` that carry `implemented`, with their implementing units. It confers + nothing and forms no ladder, and the Studio claim map becomes a projection of Spec relations and + bindings. `#referenceDirection` closes. Astra and Fable preferred a direction-neutral edge; the + Opus reading carried because every relationship then has one home. +- **R2. Anchor targets name whole Specs.** `#entryAddressTargets` closes with an entry-address + target refused: a per-entry `implemented` is the partial-realization fact under another name, + and a design key is renamed freely. The adopter runs a per-Spec pass over its twelve Specs with + designed-but-unbuilt parts, and over each component with its members. With no unrealized + promise, whether a Design entry or a rule, flow, or contract entry the Spec states, `satisfies` + stays. A coherent part with its own trigger or step splits into a child Spec that refines the + parent, and the parent is read again before it keeps `satisfies`. Anything else becomes + `references` until the code realizes what the Spec promises. Unanimous. +- **R3. Roles stay a free, corpus-owned vocabulary.** `#roleVocabulary` closes as a refused + alternative: a corpus-declared vocabulary checked by a validator is an authored registry and a + content-quality check. Two spellings of one role are reconciled by editing the anchors. + Unanimous on no validator. +- **R4. The file is the binding grain of both forms.** `#attachmentIdentity` closes: a block's + line locates the anchor and names no declaration, and no attachment kind or symbol name is + recorded. Symbol-level reach, if it arrives, is derived under the inferred claim. The authoring + skill advises keeping a file's blocks together, one identity per realizing site. +- **R5. The adopter's layer mapping stays.** The context library and the two context components + stay `adapter` and the gate stays `infrastructure`; the adopter's PLAN records a one-line rule + for each layer beside the mapping. +- **R6. The adopter's contexts stay, and the isolation is checked.** `platform` stays on the seven + platform components. `orders` and `inventory` stay on the example's domain and context + components, `orders-inventory` on the parent composition. The adopter's + `design/tools/check.py` fails when a `uses` edge runs from a `platform` component, or a member + of one, to a unit in another context: each endpoint resolves to its component, directly or + through `memberOf`, before the contexts are compared. The check reads the declared `uses` edges, + not imports. Two advisors would have dropped `platform` under the adopter's narrower glossary; the + owner's statement that the platform is the key context settles it. +- **R7. `example/` is architecture.** Its five components stay; `fixture/` stays without + components. Unanimous. +- **R8. The Protocol keeps `layer`.** It is the architecture term, the v0 field name, and the + schema `0.8.0` field. `CONTEXT.md`'s three loose uses become "level". The adopter adds an + "architectural layer" glossary row, and renaming its numbered capability layers (candidate + "stage") is queued as an owner item, because the adopter owns its language. +- **R9. MD-37 states `ready`** on the change that lands R1, R2, and R10. +- **R10. Two merge rules.** A `references` edge is authored for the design relationship it names; + an edge whose reason is a test, a count, or a `byFile` answer is removed, and the adopter's + `byFile` coverage test pins pairs derived from the anchors as they now stand. A component either + satisfies its seam's design Spec or binds no Spec its members satisfy, and never references one; + this is guidance in `sdp-authoring` and the component realization convention, checked by no + validator. + +Sequence: the Protocol side (R1 to R4, R8, R9, R10) lands as one change on +`feature/annotations-v0`, with the self-binding, recipes, oracle, and goldens, and passes the full +gate. The adopter then re-pins and applies R1, R2, R5 to R8, and R10 on its branch. After PR 29 +merges, the adopter re-pins to the merge commit. + +Review. An independent read of the rulings as landed (gpt-6-astra, read-only, `69113ff..52e8bea`) +raised seven findings, two of them holds. + +- Applied (hold). The authoring skill's `satisfies` checklist read only Design entries. It now + reads every promise a Spec carries, Design section or not, and reads a parent again after a + split before it keeps `satisfies`. R2's record above says the same. +- Applied (hold). The adopter showcase and its acceptance line still kept every sidecar pair + reachable through `byFile`, which R10 replaced. Both now pin the pairs the honest anchors + yield. +- Applied. The self-binding rule on `references` now separates the decisions a unit follows and + the designs it answers to without wholly realizing them from a Spec it wholly realizes, which it + satisfies. +- Declined. The entry-address alternative on the binding grain gives one rationale for both + target kinds, though a `references` target confers no `implemented`, and says a keyed binding + breaks "on every design edit" where only a removed or renamed key breaks it. R2 quotes that + sentence, so it stands as ruled; a sharper wording is an owner item. +- Applied. The architectural-annotation decision refused negative constraints as "a + machine-enforced graph finding", while R6 asks the adopter's gate to enforce one. The + consequence now scopes the refusal to the Protocol's validators and leaves a corpus its own + gate, as R3's alternative already does for roles. It adds no ruling. +- Applied. R6's check compared only component-to-component `uses`. The record above resolves + each endpoint to its component, directly or through `memberOf`, before comparing contexts. +- Applied. Two fixture carriers, their golden, and a recipe test label still read "written + against". They now read "answers to", and the golden was regenerated from its corpus. diff --git a/specs/decisions/anchor-binding-grain.sdp.md b/specs/decisions/anchor-binding-grain.sdp.md new file mode 100644 index 00000000..93ab1e97 --- /dev/null +++ b/specs/decisions/anchor-binding-grain.sdp.md @@ -0,0 +1,36 @@ +--- +id: spec:decisions.anchor-binding-grain +kind: decision +altitude: feature +readiness: ready +relations: + refines: spec:model.anchors + dependsOn: spec:decisions.binding-not-liveness +--- +# Bindings are optional, plural, and may reference a design + +## Intent +- outcome: Let a code anchor name zero, one, or several Specs it realizes, and name the Specs it answers to without claiming to realize them, so an anchor can state exactly what its code does and the graph can reach a design from the code that answers to it. + +## Decision +- context: A code anchor carries one required `satisfies`, a test anchor one `verifies`. The first adopter split Specs so that an anchor could claim only what its code does, a stub reads as `implemented` because the only way to name a design from code is to claim to realize it, and a relationship the design states in prose cannot reach a page. The original design made `satisfies` optional and plural on a marker (`docs/lineage/v0-design/04-authoring-surfaces.md` §2.4) and drew the line that markers carry identity and structure, never readiness, intent, behavior, or verification. The annotation scout under `docs/lineage/` found no informational code-to-Spec edge in either lineage and proposed one as the smallest extension. +- decision: On a code anchor, `satisfies` is optional and plural; on a test anchor, `verifies` is plural and non-empty. The constant form accepts one `ref(…)` or a fresh array literal of them. Each resolving target confers its fact as today, and `implemented` stays a whole-Spec fact. A new anchored edge `references` runs from a CodeNode to a Spec, from `references?: readonly SpecId[]` on a code anchor; it may target any Spec, decisions included, confers no delivery fact, moves no readiness floor, and the drift alarm ignores it. It says this code answers to that design without claiming to realize it: the unit follows the design or realizes part of it, so a change to the design asks the code to follow. A design that builds on existing code states that by a typed relation to the Spec the code satisfies, never by a reference from the code. A target named in both `satisfies` and `references` is an error. An identity-only code anchor, with no `satisfies` and no `references`, is lawful: it mints a CodeNode for structure and for `byFile`, and nothing else. +- rationale: Hard to reverse: the anchor contract and the closed edge list are contracts for the extractor, the validators, the reader, and every adopter's anchors. Surprising without context: the obvious move is to let `satisfies` name a design the code only partly realizes, and this ruling refuses that in favor of a second edge that confers nothing. Real trade-off: a non-conferring edge can be written where a realization claim is due, and nothing in the graph says which; in exchange, `implemented` keeps its one meaning and an honest anchor is always writable. +- rationale: Bindings state existence, not liveness, and `references` states less than existence of realization: only that the code answers to the design. Keeping that below the delivery facts preserves the drift alarm's meaning. +- consequence: The closed edge list gains `references`, derived and anchored; `satisfies` and `verifies` edges are one per target. +- consequence: A present `references` is non-empty and unique; a non-resolving target confers nothing and reports as a non-resolving `satisfies` target does. +- consequence: `satisfies` keeps its posture: code never satisfies a decision Spec directly (MD-26), and an anchor is never pointed at an unfinished Spec to manufacture coverage; a unit that answers to such a Spec references it. +- consequence: An identity-only anchor derives no `satisfies` edge, so it never confers `implemented`; a stub that answers to a design references the design rather than satisfying it. +- consequence: `byFile` may lawfully return a CodeNode with an empty Spec list; an empty list is the identity-only anchor's honest answer, not a lookup failure. +- consequence: `blastRadius` reports changed identity-only CodeNodes as their own list, neither dropped nor folded into coverage-unknown, because the graph records the node and nothing it binds. +- consequence: The drift alarm ignores `references` and still reports a `satisfies` target that states below `ready`, target by target. +- consequence: The build backlog predicate (recipe 1) is unchanged: `references` neither adds nor removes a row. +- consequence: The reader's Spec context names the CodeNodes that reference the Spec with their file and line; blast radius traverses `references` as a binding and names the edge type in its reason. +- consequence: The Design Review Spec page lists the units that reference the Spec, and the census renders `references` beside the structural edges. +- consequence: Anchor targets name whole Specs. An entry address never narrows a binding and never carries a delivery fact; a part of a Spec that needs its own realization tracking is its own Spec. +- consequence: A `references` edge is authored for the design relationship it names; an edge whose reason is a test, a count, or a `byFile` answer is removed. +- alternative: A partial-realization delivery fact was refused: a fact that says "partly implemented" is a status on code, and the floor cannot read it honestly. +- alternative: Folding the design relationship into `satisfies` was refused: it manufactures `implemented` on a Spec the code does not realize, which is the adopter's stub problem restated. +- alternative: Declaring the relationship on the Spec, as a list of its own code bindings, was refused: a Spec-side list of bindings is authored delivery state and a second place for the binding to drift. +- alternative: References from code that a design builds on were refused: that dependency is a relation from the building design to the Spec the code satisfies; a code-side copy is a second home for it and would make each unit's source list its consumers' designs. +- alternative: A `satisfies` or `references` target at an entry address was refused: a per-entry `implemented` is the partial-realization fact by another name, it would put delivery facts below the Spec, and a design key is renamed freely while an anchor bound to one would break on every design edit. diff --git a/specs/decisions/anchor-comment-form.sdp.md b/specs/decisions/anchor-comment-form.sdp.md new file mode 100644 index 00000000..322c1755 --- /dev/null +++ b/specs/decisions/anchor-comment-form.sdp.md @@ -0,0 +1,41 @@ +--- +id: spec:decisions.anchor-comment-form +kind: decision +altitude: feature +readiness: ready +relations: + refines: spec:model.anchors + supersedes: spec:decisions.jsdoc-graph-extraction-refused + dependsOn: spec:decisions.one-validation-path +--- +# The comment form is a second anchor representation + +## Intent +- outcome: Admit a doc comment carrying reserved `@sdp*` tags as a second extracted representation of the anchor, feeding the same closed envelope as the constant form, so code that cannot import the Protocol binds at its real site and comment prose still authors nothing. + +## Decision +- context: The source-commentary ruling it supersedes (`spec:decisions.jsdoc-graph-extraction-refused`, MD-35) refused two things in one record: comment prose authoring graph content, and identity tags in doc comments. Its rationale said the refusal "is about prose, not identity", while its operative alternative also refused structured identity tags; the annotation scout under `docs/lineage/` names that mismatch and finds that gen 1's drift was duplicate identities, scan scope, and syntax handling, never an extracted identity tag as such. The first adopter showed the cost of the single form: every one of its code anchors sits in a test or a harness because its runtime code cannot import this package, `byFile` on a source file returns nothing, and a hand-kept join stands in for the binding. The original design (`docs/lineage/v0-design/04-authoring-surfaces.md` §2) supported three marker styles feeding one extractor, and its package shape (`docs/lineage/v0-design/02-system-architecture.md` §3) kept the marker library free of runtime dependencies so product code could import it. The owner's direction opening plan 40 is to move closer to that design. +- decision: An anchor has two extracted representations that feed one closed envelope, the constant form as ruled and a comment form. The comment form is a top-level `/** … */` block in a `.ts` or `.tsx` file carrying reserved camelCase, TSDoc-compatible tags; `@sdpAnchor ` opens it, and the id's namespace selects the flavor exactly as the constant builders do. Comment prose still authors nothing: only the reserved tags are read, and a comment that does not open with `/**` is never read. Trust is by reserved grammar, not by builder import, so no import is required. The same id in two forms is a duplicate id. The package also ships a zero-dependency subpath `@libar-dev/software-delivery-protocol/anchors` exporting the id builders and the three anchor builders and nothing else, for runtimes that may import but must not load `node:*` modules or ts-morph; the extractor trusts that specifier as a Protocol builder module. +- rationale: Hard to reverse: a second write path from source becomes a contract for the extractor, the validators, and every adopter's code. Surprising without context: the superseded ruling refused the obvious parity move, and this one admits exactly the half its own rationale left open while keeping the half it was about. Real trade-off: a tag grammar in a comment gets no compiler check and the extractor gains a second parser; in exchange, code that cannot import the package binds at its real site and `byFile` answers for it. +- rationale: What survives from the superseded ruling: comment prose authors nothing; a comment's wording beyond the reserved tags produces no graph finding; a comment that states law other surfaces depend on still promotes into a Spec under the promotion law, and restating promoted law in a comment violates exclusive promotion (MD-10), so the comment demotes to local commentary plus a Spec pointer; one graph language stays, because both forms feed one envelope and one extraction path (one validation path, MD-14). +- consequence: The reserved tags are `@sdpAnchor`, `@sdpLabel`, `@sdpSatisfies`, `@sdpVerifies`, `@sdpModels`, `@sdpReferences`, `@sdpComponent`, `@sdpUses`, `@sdpRole`, `@sdpLayer`, and `@sdpContext`; the spelling is camelCase so TSDoc tooling reads each as one tag. +- consequence: `@sdpAnchor ` opens the anchor; `impl:`, `api:`, and `component:` ids are code anchors, `test:` is a test anchor, `oracle:` is an oracle anchor. +- consequence: Target tags by flavor are `@sdpSatisfies` on a code anchor, `@sdpVerifies` on a test anchor, and `@sdpModels` on an oracle anchor; lists are comma-separated. +- consequence: Structural tags are admitted on code anchors only: `@sdpComponent`, `@sdpUses`, `@sdpReferences`, `@sdpRole`; a component anchor additionally takes `@sdpLayer` and `@sdpContext`. +- consequence: `@sdpLabel` is admitted on any flavor. +- consequence: Any other `@sdp*` tag is an envelope error, because the envelope is closed exactly as it is in the constant form; a line that opens with `@sdp` is a reserved tag line, so a misspelling such as `@sdp-anchor` or `@sdpAnchor:` is refused rather than read as prose. +- consequence: Attachment: every top-level `/** … */` block that contains a reserved tag is one anchor with exactly one `@sdpAnchor`; a block with reserved tags and no `@sdpAnchor` is an envelope error, and a block with two is an envelope error, as any repeated tag is. +- consequence: Blocks above the first import, trailing blocks with no statement beneath them, and comment-only files are read; a `/** … */` block with a reserved tag in a nested position, inside a function body, a class member, an object literal, or a JSX expression, is a misplaced-tag error, and a nested comment the top level would not read stays unread. +- consequence: The block's first line is the binding's file and line. +- consequence: The binding grain of both forms is the file: a block's line locates the anchor and names no declaration, and no attachment kind or symbol name is recorded. Symbol-level reach, if it arrives, is derived under the inferred claim and joins anchors by position; a mismatch is never a finding. +- consequence: Cardinalities: each reserved tag appears at most once per block; `@sdpModels` takes one target; a list tag is non-empty, carries no empty item, and repeats no target. +- consequence: Prose precedes the tags; after the first reserved tag, a non-empty line that does not open a tag is a refused continuation, and an ordinary TSDoc tag such as `@param` stays lawful and unread. +- consequence: Parsing reads the raw comment text with a tag grammar; it does not depend on the TypeScript JSDoc tag parser. +- consequence: The `hasProtocolBuilderImport` prefilter admits a file that contains the `@sdp` prefix. +- consequence: The same id written in both forms reports through the existing duplicate-id validator. +- consequence: The `/anchors` subpath is ESM with type declarations, built from the leaf id and anchor modules, carries no dependency, loads no `node:*` module, and its dependency closure is tested; a runtime that imports it ships nothing beyond the id and anchor builders. +- consequence: The decorator form stays an unextracted representation. +- alternative: Keeping the constant form as the single binding syntax was refused: the adopter's runtime code cannot import this package, so its anchors would stay in tests and the graph would keep answering nothing for source files. +- alternative: Extracting the comment form through the TypeScript JSDoc tag parser was refused: it would couple the reserved grammar to the compiler's tag model and its versioning, where a tag grammar over the raw comment text is owned here. +- alternative: A hyphenated spelling such as `@sdp-anchor` was refused: TSDoc tooling splits a hyphenated tag at the hyphen, so the tag would render as `@sdp` with a stray suffix in every documentation projection. +- alternative: Parsing comment prose into Spec sections stays refused: it inverts the separation of intent from binding and recreates a shadow intent carrier beside the Specs. diff --git a/specs/decisions/architectural-annotation.sdp.md b/specs/decisions/architectural-annotation.sdp.md new file mode 100644 index 00000000..b4b14a3e --- /dev/null +++ b/specs/decisions/architectural-annotation.sdp.md @@ -0,0 +1,50 @@ +--- +id: spec:decisions.architectural-annotation +kind: decision +altitude: feature +readiness: ready +relations: + refines: spec:model.anchors + supersedes: + - spec:decisions.structural-anchor-semantics + - spec:decisions.architectural-significance-rides-primitives + dependsOn: + - spec:decisions.binding-not-liveness + - spec:decisions.anchor-binding-grain +--- +# Architectural significance is annotated where it is realized + +## Intent +- outcome: Let a code anchor state the architectural role its unit plays, and let a component anchor state its layer and bounded context, so the graph answers architecture questions from the code that realizes the architecture, without a status, a second vocabulary registry, or a second architecture file. + +## Decision +- context: Two rulings carried the structural half of the anchor: structural anchors confer nothing (`spec:decisions.structural-anchor-semantics`, MD-30) admitted `component` and `uses` as the only structural fields, and architectural significance rides existing primitives (`spec:decisions.architectural-significance-rides-primitives`, MD-34) refused a vocabulary beyond them and gave up CodeNode-grain roles. Both cited gen 1's tag-registry drift, about 50 tags to about 26, as the failure of an open structural vocabulary. The annotation scout under `docs/lineage/` corrects that account: the 50-to-26 collapse was a cleanup of gen 1's formal vocabulary that never shipped as a runtime registry, and what drifted there was duplicate identities, scan scope, and syntax handling. The original design declared a Component with `layer` and `bounded_context` (`docs/lineage/v0-design/03-graph-metamodel.md` §2.5), and gen 1 carried a role on each annotated unit. The owner's direction opening plan 40 is to bring back the value of annotating architecturally significant units and their relationships in code. +- decision: Architectural significance is annotated on the anchor of the unit that realizes it. A code anchor may carry `role`, the architectural pattern the unit plays; a `component:` anchor may additionally carry `layer`, one of `edge`, `application`, `domain`, `adapter`, or `infrastructure`, and `context`, its bounded context. The `component:` anchor is anchored architecture: it is the declared component, and there is no separate architecture file, because the anchor sits where the component is realized. It is not the original design's declared Component that could exist before code; an unrealized component lives in Specs and never as an invented CodeNode. A role is a free, corpus-owned vocabulary whose taxonomy the census renders with counts from the graph; `service`, `decider`, `projection`, `read-model`, `codec`, `contract`, `barrel`, and `utility` are the lineage set, not a closed list. `layer` is a closed set, and a value outside it is an envelope error; `layer` or `context` on a non-component anchor is an envelope error. All three attributes are optional, and omission is lawful. +- rationale: Hard to reverse: the anchor envelope and the CodeNode fields are contracts for the extractor, the census, the Design Review, and every adopter's annotations. Surprising without context: the superseded rulings refused exactly these attributes on the strength of a drift account the scout found inaccurate, so the reversal reads as a repeat of gen 1 until the correction is on the table. Real trade-off: a free role vocabulary can drift across a corpus and no validator will say so; in exchange, the role, layer, and context live on the one anchor at the one site, and the census taxonomy makes drift visible without a registry. +- rationale: What survives from both superseded rulings is restated here as consequences: the `component` and `uses` semantics and their validation rules, structural non-conferral, no inference from imports, no status or readiness on code, significance never selects a Spec kind, no `pattern:` namespace, and the engine significance criterion. +- consequence: `component?: ComponentAnchorId` and `uses?: readonly CodeAnchorId[]` stay closed graph-ID references deriving only anchored `memberOf` and `uses` edges; neither accepts a free string or an enum detached from graph identity, and there are no per-namespace sibling builders. +- consequence: Every `component` and `uses` target exists as a CodeNode, and a dangling graph id is an error. +- consequence: Membership is one level: a `memberOf` edge runs only from an `impl:` or `api:` CodeNode to a `component:` CodeNode, each source has at most one component, and a `component:` node is never itself a member. +- consequence: A `uses` edge runs between CodeNode endpoints in the `impl:`, `api:`, or `component:` namespace; a present `uses` is non-empty and unique, structural edges are unique, and structural self-reference is an error. +- consequence: Multi-node `uses` cycles remain authored data for the census, never findings; validators infer no transitive edge and reject no cycle. +- consequence: A malformed or non-static structural field, an unknown `layer`, or `layer` or `context` on a non-component anchor refuses the whole anchor at reification rather than yielding a partial declaration; the envelope stays closed. +- consequence: Structural edges and the new attributes confer no intent, delivery fact, or readiness effect; `satisfies` stays the only code-to-Spec realization slot and no `implements` field is admitted. +- consequence: Architecture is never inferred from imports as anchored or declared structure; an inferred structural layer, if one arrives, stays advisory under its own claim, and derivation adds no fourth claim. +- consequence: No status, readiness, intent, behavior, or verification is carried on code; role, layer, and context describe what the unit is, never where it stands. +- consequence: Anchor-required lint stays warn-level and optional; the absence of an anchor is evidence, never a workflow gate. +- consequence: Architectural significance never selects a Spec kind; the Spec states the kind of truth it carries, and relationships among such Specs are the existing relations, with `dependsOn` reserved for genuine semantic need, `supersedes` for actual replacement, and scheduling-flavored edges refused (planning truths live in ruled graph homes, MD-33). +- consequence: Negative architecture constraints, a layer that must not use another, stay declared intent in a Spec, never a finding of the Protocol's validators; a corpus that wants one enforced checks its declared `uses`, `layer`, and `context` in its own gate. +- consequence: No `pattern` term, kind, or `pattern:` namespace is admitted; a role is an attribute of a CodeNode, not a node; grouping is derived from id families and the component graph, not new Packs. +- consequence: The engine significance criterion stays exported public surface or cross-component reach; code never satisfies a decision Spec directly (MD-26), and an anchor is never pointed at an unfinished Spec to manufacture coverage. +- consequence: `role` and `context` values are one lowercase kebab token matching `^[a-z][a-z0-9-]*$`, with no normalization, so `read-model`, `readModel`, and `Read Model` cannot coexist as three categories; a value outside the token grammar is an envelope error. +- consequence: The census links each role, layer, and context value to the units that carry it, so an owner can reconcile two spellings of one category by editing the anchors; nothing forces a label, and an unlabelled unit does not populate the taxonomy. +- consequence: The five layers, one line each: `edge` is the transport and presentation boundary; `application` is use cases and orchestration; `domain` is the model and its rules; `adapter` is the implementations of ports toward external systems; `infrastructure` is runtime, persistence, and platform plumbing. +- consequence: A package may hold several layers or several contexts; the package-to-component mapping is reviewed, and one component per package is a choice, never a rule. +- consequence: Retained limitation: there is no independently declared Component; a component that no code realizes yet is a Spec, and the graph holds no CodeNode for it until an anchor exists. +- consequence: The census renders role, layer, context, and `references` beside the structural edges it already renders, reporting an explicit empty state when none exist, and the Design Review's component rows show layer and context; both render under their existing contracts, Mermaid and Gherkin are untouched, and the shipped projections stay frozen (MD-32) as it stands, because a frozen projection renders the graph it is given and no projection is re-specified. +- consequence: Annotations are curated, never a coverage quota; an identity has one owner; a realizing unit documents its local how, never a paraphrase of the Spec's what or why. +- alternative: A separate architecture file declaring components, layers, and contexts was refused: it is a second place for the component to drift from the code that realizes it. +- alternative: A closed role enum was refused: the corpus owns its architecture vocabulary, and a closed set would be the Protocol's architecture, not the adopter's. +- alternative: A corpus-declared role vocabulary checked by a validator was refused: it is an authored registry, and the check is a content-quality check. The token grammar and the census taxonomy with its units are the whole check; a corpus that wants a list lints recipe 28 in its own gate. +- alternative: Deriving role, layer, or context from file paths or imports was refused: it turns incidental layout into authoritative architecture and misclassifies an inferred observation as an anchored claim. +- alternative: Adding `implements` was refused again: it would duplicate `satisfies` for contract-kind targets and make code-to-Spec realization ambiguous. diff --git a/specs/extraction/derive-graph.sdp.md b/specs/extraction/derive-graph.sdp.md index 1b44500c..18222e1e 100644 --- a/specs/extraction/derive-graph.sdp.md +++ b/specs/extraction/derive-graph.sdp.md @@ -21,7 +21,9 @@ forward pointer between records that still exist. ## Behavior - rule: Carrier reification feeds deriveGraph once; no consumer creates a second graph. - rule: The graph is flat arrays of typed nodes and edges; hierarchy and containment are expressed by edges rather than nested nodes. -- rule: Declared relations resolve Primitive to Primitive, while `satisfies` and test `verifies` edges derive from anchors and run from their binding node to the direct Spec target. +- rule: Declared relations resolve Primitive to Primitive, while `satisfies`, `references`, and test `verifies` edges derive from anchors and run from their binding node to the direct Spec target, one edge per target. +- rule: The edge list is closed at twelve types: the six declared relations `refines`, `dependsOn`, `constrainedBy`, `decidedBy`, `verifies`, and `supersedes`, and the six derived edges `belongsTo`, `satisfies`, `models`, `memberOf`, `uses`, and `references`. +- rule: A `references` edge is anchored, runs from a CodeNode to the Spec its code answers to, and contributes no delivery fact. - rule: Delivery facts are computed node facts: a resolving `satisfies` edge contributes `implemented`, and an enabled direct verifier contributes `has-verifier` only to its target. - rule: Inferred structural edges are advisory inputs to impact analysis and never become authoritative graph truth. - rule: The key order of `design`, `ui`, and `model.terms` is the authored order and part of the graph contract. diff --git a/specs/extraction/pack-member-order.manifest-order-kept.sdp.md b/specs/extraction/pack-member-order.manifest-order-kept.sdp.md index 16526d43..e9b6f548 100644 --- a/specs/extraction/pack-member-order.manifest-order-kept.sdp.md +++ b/specs/extraction/pack-member-order.manifest-order-kept.sdp.md @@ -19,5 +19,5 @@ When the graph is derived and serialized Then the serialized Pack node lists the members {serializedMembers: "spec:probe.zeta, spec:probe.alpha, spec:probe.mid"} Then the serialized belongsTo edges run from {edgeSources: "spec:probe.alpha, spec:probe.mid, spec:probe.zeta"} Then the reader's Pack context lists the members {readerMembers: "spec:probe.zeta, spec:probe.alpha, spec:probe.mid"} -Then the payload declares the schema version {schemaVersion: "0.7.0"} +Then the payload declares the schema version {schemaVersion: "0.8.0"} ``` diff --git a/specs/extraction/schema-versioning.declared-version.sdp.md b/specs/extraction/schema-versioning.declared-version.sdp.md index c8a83f52..2db7b711 100644 --- a/specs/extraction/schema-versioning.declared-version.sdp.md +++ b/specs/extraction/schema-versioning.declared-version.sdp.md @@ -15,5 +15,5 @@ relations: ```gwt Given a graph derived from the authored spec {specId: "spec:probe.schema-versioning"} When the graph payload is serialized -Then the payload declares the schema version {schemaVersion: "0.7.0"} +Then the payload declares the schema version {schemaVersion: "0.8.0"} ``` diff --git a/specs/model/anchors.sdp.md b/specs/model/anchors.sdp.md index f57c3ac8..62bc91f3 100644 --- a/specs/model/anchors.sdp.md +++ b/specs/model/anchors.sdp.md @@ -6,9 +6,10 @@ readiness: ready relations: refines: spec:model.core-model decidedBy: - - spec:decisions.jsdoc-graph-extraction-refused + - spec:decisions.anchor-comment-form + - spec:decisions.anchor-binding-grain + - spec:decisions.architectural-annotation - spec:decisions.binding-not-liveness - - spec:decisions.structural-anchor-semantics --- # Source anchors bind code without carrying intent @@ -16,18 +17,27 @@ relations: - outcome: Connect implementation, tests, and oracles to Specs while keeping authored intent centralized in the carrier. ## Model -- **anchor** — A human-written source binding from one code location to one Spec ID, carrying identity, an optional label, and one realization target only. -- **code anchor** — An implementation-flavored binding that derives an anchored satisfies edge. It may additionally name one `component?: ComponentAnchorId` and a non-empty, unique `uses?: readonly CodeAnchorId[]`; these closed graph-ID references derive only anchored CodeNode-to-CodeNode `memberOf` and `uses` edges. -- **component realization convention** — A `component:` anchor satisfies its seam's most-specific design Spec; the `satisfies` edge is the component's own realization claim. Structural edges confer nothing, so MD-30 remains unchanged. -- **structural anchor validity** — A `memberOf` source is an `impl:` or `api:` CodeNode and its target is a `component:` CodeNode; every structural target exists, every edge is unique, each source has at most one component, and structural self-reference is refused. A malformed or non-static structural field refuses the whole anchor at reification; a graph-validly reified edge that later fails referential or structural validation remains visible with its anchor and independent `satisfies` binding. Multi-node `uses` cycles remain data and produce no finding. -- **structural non-conferral** — Structural edges carry no intent, delivery fact, readiness effect, or binding-to-Spec traversal; no `implements` field is admitted. -- **test anchor** — A binding that derives an anchored verifies edge from a test to its target Spec. -- **oracle anchor** — A binding that records an oracle's models target without deriving a delivery fact. +- **anchor** — A human-written source binding from one code location into the graph, carrying identity, an optional label, zero or more targets, and optional structure; written in one of two extracted representations, the constant form or the comment form, which feed one closed envelope. It never carries behavior, rationale, readiness, status, acceptance criteria, or delivery facts. +- **constant form** — The top-level const initialized with a trusted builder call; the extractor reifies it statically. It was formerly named the anchor-constant form. The decorator form remains an unextracted representation. +- **comment form** — A top-level `/** … */` block in a `.ts` or `.tsx` file carrying reserved camelCase tags (`@sdpAnchor`, `@sdpLabel`, `@sdpSatisfies`, `@sdpVerifies`, `@sdpModels`, `@sdpReferences`, `@sdpComponent`, `@sdpUses`, `@sdpRole`, `@sdpLayer`, `@sdpContext`); `@sdpAnchor ` opens it, each tag appears at most once, prose precedes the tags, only the reserved tags are read, and comment prose authors nothing. No import is required: trust is by reserved grammar. The same id in both forms is a duplicate id. The law is the comment-form ruling (MD-36). +- **anchors subpath** — The zero-dependency package subpath `@libar-dev/software-delivery-protocol/anchors`, exporting the id builders and the three anchor builders and nothing else; the extractor trusts it as a Protocol builder module. +- **code anchor** — An implementation-flavored binding in the `impl:`, `api:`, or `component:` namespace. Its optional, plural `satisfies` derives one anchored `satisfies` edge per target; its optional `references` derives one anchored `references` edge per target. It may additionally name one `component?: ComponentAnchorId`, a non-empty, unique `uses?: readonly CodeAnchorId[]`, and a `role`; a `component:` anchor may also name `layer` and `context`. The binding grain is the binding-grain ruling (MD-37); the structural attributes are the architectural-annotation ruling (MD-38). +- **plural targets** — `satisfies` on a code anchor and `verifies` on a test anchor accept one `ref(…)` or a fresh array literal of them; `verifies` is non-empty. Each resolving target confers its fact as a single target does, and `implemented` stays a whole-Spec fact. +- **identity-only anchor** — A code anchor with no `satisfies` and no `references`; it mints a CodeNode for structure and for `byFile`, derives no realization edge, and confers nothing. +- **references** — The anchored CodeNode-to-Spec edge derived from `references?: readonly SpecId[]` on a code anchor: this code answers to that design without claiming to realize it. The unit follows the design or realizes part of it, so a change to the design asks the code to follow. The direction is one way: a design that builds on existing code states that on its own side, by a `dependsOn` or `refines` relation to the Spec the code satisfies, never by a reference from the code. It confers no delivery fact, moves no readiness floor, and the drift alarm ignores it; a target also named in `satisfies` is an error. The reader reports the referencing units on the Spec's context, and blast radius traverses the edge as a binding. +- **role** — An optional free string on a code anchor naming the architectural pattern the unit plays; a corpus-owned vocabulary whose taxonomy the census renders with counts, checked against no list. +- **layer** — An optional attribute of a `component:` anchor, one of `edge`, `application`, `domain`, `adapter`, or `infrastructure`; a value outside the set, or the attribute on a non-component anchor, is an envelope error. +- **context** — An optional free string on a `component:` anchor naming its bounded context; on a non-component anchor it is an envelope error. +- **declared component** — The `component:` anchor is the component's declaration; there is no separate architecture file, because the anchor sits where the component is realized. +- **component realization convention** — A `component:` anchor that satisfies a Spec satisfies its seam's most-specific design Spec; that `satisfies` edge is the component's own realization claim. As guidance, checked by no validator: a component either satisfies its seam's design Spec or binds no Spec its members satisfy, and it never references one. Structural edges confer nothing under the architectural-annotation ruling (MD-38), which supersedes structural anchors confer nothing (MD-30) without changing this. +- **structural anchor validity** — A `memberOf` source is an `impl:` or `api:` CodeNode and its target is a `component:` CodeNode; every structural target exists, every edge is unique, each source has at most one component, and structural self-reference is refused. A malformed or non-static structural field, an unknown `layer`, or `layer` or `context` on a non-component anchor refuses the whole anchor at reification; a graph-validly reified edge that later fails referential or structural validation remains visible with its anchor and independent `satisfies` binding. Multi-node `uses` cycles remain data and produce no finding. +- **structural non-conferral** — Structural edges, `role`, `layer`, and `context` carry no intent, delivery fact, readiness effect, or binding-to-Spec traversal; no `implements` field is admitted. +- **test anchor** — A binding in the `test:` namespace that derives one anchored `verifies` edge per target from a test to each Spec it verifies. +- **oracle anchor** — A binding in the `oracle:` namespace that records an oracle's `models` target without deriving a delivery fact. - **executable binding boundary** — A resolving `specTest` anchor can establish verifier realization; a `bindExample` call executes a generated contract but is not extracted graph data, so the graph cannot claim from that call alone that the contract is bound. - **document-realization binding** — When the realizing artifact is authored Markdown that cannot carry an extracted in-code anchor, the executable suite that asserts the shipped document may carry its code anchor. Its label must name the document realization rather than imply the test body is the product, and file-level blast radius remains coverage-unknown for the Markdown artifact. -- **anchor-constant form** — The top-level const builder call that the MVP extractor reifies; decorator and JSDoc forms remain unextracted representations. -- **Protocol builder binding** — A builder import from the public Protocol package, or a relative import whose importer-relative resolution — including the TypeScript `.js`-to-`.ts` convention — canonicalizes to this package's `ids` or `model/code-anchor` module; consumer-local lookalike modules confer no binding authority. On the CommonJS package surface the trusted relative-module set is empty (`import.meta.url` is rewritten away), so relative bindings mint no anchors there while package imports stay trusted. -- **untrusted builder** — A builder call whose import is no Protocol builder binding: it mints nothing and reports nothing, because a source file that never bound to the Protocol is not authoring drift to report. The realizing entrypoints are `protocolBindingScopeFor` and `collectProtocolBindings` in `src/extract/protocol-bindings.ts`. +- **Protocol builder binding** — A builder import from the public Protocol package or its anchors subpath, or a relative import whose importer-relative resolution, including the TypeScript `.js`-to-`.ts` convention, canonicalizes to this package's `ids` or `model/code-anchor` module; consumer-local lookalike modules confer no binding authority. On the CommonJS package surface the trusted relative-module set is empty (`import.meta.url` is rewritten away), so relative bindings mint no anchors there while package imports stay trusted. The comment form needs no builder binding. +- **untrusted builder** — A constant-form builder call whose import is no Protocol builder binding: it mints nothing and reports nothing, because a source file that never bound to the Protocol is not authoring drift to report. The realizing entrypoints are `protocolBindingScopeFor` and `collectProtocolBindings` in `src/extract/protocol-bindings.ts`. ## Example space ```gwt-vocabulary diff --git a/specs/model/structural-patterns.sdp.md b/specs/model/structural-patterns.sdp.md index da5015dc..b7be7d81 100644 --- a/specs/model/structural-patterns.sdp.md +++ b/specs/model/structural-patterns.sdp.md @@ -5,14 +5,16 @@ altitude: feature readiness: defined relations: refines: spec:model.anchors - decidedBy: spec:decisions.architectural-significance-rides-primitives + decidedBy: spec:decisions.architectural-annotation --- -# Architectural significance dissolves into existing primitives +# Architecturally significant units are annotated where they are realized ## Intent -- outcome: Express architectural responsibilities in Specs of the appropriate kind, connect their semantic dependencies with existing relations, and bind their implementations through structural anchors. Architectural significance adds no kind or vocabulary. +- outcome: Annotate each architecturally significant unit on the anchor of the code that realizes it, with its role, its component's layer and bounded context, its uses, and the designs it references, so the graph answers architecture questions from the code itself. Architectural significance adds no Spec kind, no node type, and no vocabulary registry. ## Model -- **architecturally significant unit** — a code unit with exported public surface or cross-component reach that warrants graph-visible structural binding. +- **architecturally significant unit** — A code unit with exported public surface or cross-component reach that warrants graph-visible structural binding: component membership, uses declarations for its architectural dependencies, a role, and `references` to the designs it answers to. +- **structural attributes** — `role` on any code anchor; `layer` and `context` on a `component:` anchor; all three describe what the unit is, never where it stands, and the census renders their taxonomy from the graph. +- **accepted set** — The set of architecturally significant units in a corpus is an owner-reviewed declaration, never derived from imports or exports; annotations are curated, never a coverage quota. diff --git a/specs/protocol/structural-self-binding.sdp.md b/specs/protocol/structural-self-binding.sdp.md index 472da687..ad572655 100644 --- a/specs/protocol/structural-self-binding.sdp.md +++ b/specs/protocol/structural-self-binding.sdp.md @@ -5,13 +5,13 @@ altitude: story readiness: defined relations: refines: spec:protocol.self-hosting - decidedBy: spec:decisions.architectural-significance-rides-primitives + decidedBy: spec:decisions.architectural-annotation --- # The engine's structural self-binding covers its architecturally significant units ## Intent -- outcome: Every architecturally significant engine unit carries component membership and uses declarations so structural recipes and the census answer architecture questions about the engine itself. +- outcome: Every architecturally significant engine unit carries component membership, uses declarations, and a role, every engine component declares its layer and context, and a unit references the decisions it answers to, so structural recipes and the census answer architecture questions about the engine itself. ## Behavior @@ -19,3 +19,6 @@ relations: - rule: Every architecturally significant unit is covered at Spec-realization grain: it carries component membership through the anchor of the Spec it honestly realizes, or — for an implementation helper with no honest satisfies target of its own — through the nearest honest realization anchor that consumes it; it also carries uses declarations for each component it architecturally depends on, so structural recipes answer dependency questions about the engine itself. - rule: A component-level uses declaration tracks real imports, value or type, from another component's source files; imports that exist only to author the anchors themselves (the stable-id and anchor-builder modules) confer no edge. - rule: The accepted set of architecturally significant units is an owner-reviewed declaration recorded in the self-hosting oracle, never derived from imports or exports; the suite census-checks that every accepted unit carries its declared membership and that no unrostered membership edge exists. +- rule: Every engine `component:` anchor declares its `layer` and its `context`. +- rule: Every architecturally significant engine unit carries a `role`. +- rule: An engine unit references the decisions it follows, and the designs it answers to without wholly realizing them, through `references`; it satisfies only a Spec it wholly realizes, never a decision; a Spec Studio design that builds on engine code states that by its own `dependsOn` or `refines` to the Spec the code satisfies, never by a reference from the unit. diff --git a/src/adapters/vitest.ts b/src/adapters/vitest.ts index c561961a..ad69b1d0 100644 --- a/src/adapters/vitest.ts +++ b/src/adapters/vitest.ts @@ -20,6 +20,8 @@ const adaptersComponentAnchor = codeAnchor({ label: "Protocol Vitest adapter seam", satisfies: ref("spec:extraction.example-runner"), uses: [componentAnchorId("component:protocol.runner")], + layer: "adapter", + context: "protocol", }); const exampleRunnerAdapterAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.example-runner-adapter"), @@ -27,6 +29,7 @@ const exampleRunnerAdapterAnchor = codeAnchor({ satisfies: ref("spec:extraction.example-runner"), component: componentAnchorId("component:protocol.adapters"), uses: [codeAnchorId("impl:protocol.example-runner")], + role: "adapter", }); void adaptersComponentAnchor; void exampleRunnerAdapterAnchor; diff --git a/src/anchors.ts b/src/anchors.ts new file mode 100644 index 00000000..b56325c2 --- /dev/null +++ b/src/anchors.ts @@ -0,0 +1,43 @@ +/** + * The zero-dependency anchor subpath, `@libar-dev/software-delivery-protocol/anchors` (the + * comment-form decision, `spec:decisions.anchor-comment-form`; v0 02 §3): the id builders and the + * three anchor builders, and nothing else. A runtime that may import but must never load + * `node:*` or ts-morph binds its constant-form anchors through this entry; the extractor trusts + * the specifier exactly as it trusts the bare package specifier. + */ +/** + * @sdpAnchor impl:protocol.anchors-subpath + * @sdpLabel zero-dependency subpath exporting the id and anchor builders only + * @sdpSatisfies spec:model.anchors + * @sdpComponent component:protocol.model + * @sdpReferences spec:decisions.anchor-comment-form + * @sdpRole contract + */ +export { + anchorId, + codeAnchorId, + componentAnchorId, + oracleAnchorId, + packId, + ref, + specId, + testAnchorId, + CODE_ANCHOR_NAMESPACES, +} from "./ids.js"; +export type { + AnchorId, + CodeAnchorId, + ComponentAnchorId, + OracleAnchorId, + PackId, + SpecId, + TestAnchorId, +} from "./ids.js"; +export { codeAnchor, specOracle, specTest, CODE_ANCHOR_LAYERS } from "./model/anchors.js"; +export type { + Anchor, + CodeAnchor, + CodeAnchorLayer, + SpecOracleAnchor, + SpecTestAnchor, +} from "./model/anchors.js"; diff --git a/src/cli/build-command.ts b/src/cli/build-command.ts index f471c209..f541bfcb 100644 --- a/src/cli/build-command.ts +++ b/src/cli/build-command.ts @@ -35,6 +35,8 @@ const cliComponentAnchor = codeAnchor({ componentAnchorId("component:protocol.graph"), componentAnchorId("component:protocol.model"), ], + layer: "edge", + context: "protocol", }); export interface BuildHooks { @@ -207,24 +209,28 @@ const wholesaleViewBuildInvalidationAnchor = codeAnchor({ label: "invalidates the prior Design Review before every build attempt", satisfies: ref("spec:consumers.wholesale-view-rewrite"), component: componentAnchorId("component:protocol.cli"), + role: "service", }); const buildPipelineEmitAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.build-pipeline-emit"), label: "the build command owns the ordered flow through artifact emission", satisfies: ref("spec:extraction.build-pipeline"), component: componentAnchorId("component:protocol.cli"), + role: "service", }); const determinismAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.extraction-determinism"), label: "repeats and byte-compares graph and contract generation under --check-clean", satisfies: ref("spec:extraction.determinism"), component: componentAnchorId("component:protocol.cli"), + role: "service", }); const regenerabilityAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.regenerability"), label: "repeats graph and contract producers for deterministic regeneration", satisfies: ref("spec:extraction.regenerability"), component: componentAnchorId("component:protocol.cli"), + role: "service", }); export function runBuild( parsed: BuildArgs, diff --git a/src/cli/census-command.ts b/src/cli/census-command.ts index 9fd3e89a..e264b13a 100644 --- a/src/cli/census-command.ts +++ b/src/cli/census-command.ts @@ -67,6 +67,7 @@ const censusPageCliAnchor = codeAnchor({ label: "publishes the census through the explicit `sdp census` surface", satisfies: ref("spec:consumers.census-page"), component: componentAnchorId("component:protocol.cli"), + role: "publisher", }); void censusPageCliAnchor; diff --git a/src/cli/gherkin-command.ts b/src/cli/gherkin-command.ts index e3e70bbd..8263da54 100644 --- a/src/cli/gherkin-command.ts +++ b/src/cli/gherkin-command.ts @@ -67,6 +67,7 @@ const gherkinViewCliAnchor = codeAnchor({ label: "publishes the Gherkin view through the explicit `sdp gherkin` surface", satisfies: ref("spec:consumers.gherkin-view"), component: componentAnchorId("component:protocol.cli"), + role: "publisher", }); void gherkinViewCliAnchor; diff --git a/src/cli/import-command.ts b/src/cli/import-command.ts index d9e1e73b..7676433a 100644 --- a/src/cli/import-command.ts +++ b/src/cli/import-command.ts @@ -73,6 +73,7 @@ const sdpImportAnchor = codeAnchor({ label: "plans, refuses, and publishes TypeScript-to-Markdown Spec imports", satisfies: ref("spec:carrier.sdp-import"), component: componentAnchorId("component:protocol.cli"), + role: "service", }); void sdpImportAnchor; diff --git a/src/cli/mermaid-command.ts b/src/cli/mermaid-command.ts index 706c0098..d47362cb 100644 --- a/src/cli/mermaid-command.ts +++ b/src/cli/mermaid-command.ts @@ -83,6 +83,7 @@ const mermaidViewCliAnchor = codeAnchor({ label: "publishes Mermaid through the explicit `sdp mermaid` surface", satisfies: ref("spec:consumers.mermaid-view"), component: componentAnchorId("component:protocol.cli"), + role: "publisher", }); void mermaidViewCliAnchor; diff --git a/src/cli/new-spec-command.ts b/src/cli/new-spec-command.ts index 359b3385..d8f33e7e 100644 --- a/src/cli/new-spec-command.ts +++ b/src/cli/new-spec-command.ts @@ -361,6 +361,7 @@ const specScaffolderAnchor = codeAnchor({ label: "scaffolds an honest idea-rung Markdown Spec through `sdp new spec`", satisfies: ref("spec:consumers.authoring-on-ramp"), component: componentAnchorId("component:protocol.cli"), + role: "service", }); void specScaffolderAnchor; diff --git a/src/cli/output.ts b/src/cli/output.ts index 06a6a5f2..0d7139ca 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -25,6 +25,7 @@ const diagnosticCliAnchor = codeAnchor({ label: "composes structured finding fields into the one command-line diagnostic form", satisfies: ref("spec:validation.diagnostic-rendering"), component: componentAnchorId("component:protocol.cli"), + role: "renderer", }); void diagnosticCliAnchor; diff --git a/src/cli/q-command.ts b/src/cli/q-command.ts index 6317bf57..d02bb690 100644 --- a/src/cli/q-command.ts +++ b/src/cli/q-command.ts @@ -72,7 +72,7 @@ async function asyncBodyShape(): Promise { * syntax — so the runner never has to resolve a module on the operator's behalf and no staleness * switch exists to forget. `return` is the output contract. */ -function compileBody(source: string): CompiledBody { +export function compileBody(source: string): CompiledBody { const { constructor: AsyncBody } = Object.getPrototypeOf(asyncBodyShape) as { readonly constructor: AsyncBodyConstructor; }; @@ -197,6 +197,7 @@ const buildPipelineQueryAnchor = codeAnchor({ "one query invocation derives the graph once and serves reader, raw graph, and report from that one derivation", satisfies: ref("spec:extraction.build-pipeline"), component: componentAnchorId("component:protocol.cli"), + role: "service", }); void buildPipelineQueryAnchor; diff --git a/src/cli/sdp.ts b/src/cli/sdp.ts index 23f9289b..bb77c900 100644 --- a/src/cli/sdp.ts +++ b/src/cli/sdp.ts @@ -3,8 +3,6 @@ import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; -import { codeAnchor } from "../model/code-anchor.js"; import { parseBuildArgs } from "./build-args.js"; import { runBuild } from "./build-command.js"; import { runCensus } from "./census-command.js"; @@ -113,13 +111,13 @@ interface CliHooks extends CensusHooks, MermaidHooks, GherkinViewHooks, Validate readonly query?: QueryHooks; } -const agentSurfaceCliAnchor = codeAnchor({ - id: codeAnchorId("impl:protocol.agent-surface-cli"), - label: "CLI verb dispatcher and agent-surface front of the package CLI", - satisfies: ref("spec:consumers.agent-surface"), - component: componentAnchorId("component:protocol.cli"), -}); -void agentSurfaceCliAnchor; +/** + * @sdpAnchor impl:protocol.agent-surface-cli + * @sdpLabel CLI verb dispatcher and agent-surface front of the package CLI + * @sdpSatisfies spec:consumers.agent-surface + * @sdpComponent component:protocol.cli + * @sdpRole service + */ /** * Every verb but `q` and `validate --watch` completes synchronously. `q` awaits an diff --git a/src/cli/validate-view-command.ts b/src/cli/validate-view-command.ts index 2bf09477..24aa4efc 100644 --- a/src/cli/validate-view-command.ts +++ b/src/cli/validate-view-command.ts @@ -59,6 +59,7 @@ const wholesaleViewRewriteAnchor = codeAnchor({ label: "publishes each Design Review as one wholesale temporary-directory replacement", satisfies: ref("spec:consumers.wholesale-view-rewrite"), component: componentAnchorId("component:protocol.cli"), + role: "publisher", }); void wholesaleViewRewriteAnchor; diff --git a/src/codegen/contracts.ts b/src/codegen/contracts.ts index 6f570e80..3261c042 100644 --- a/src/codegen/contracts.ts +++ b/src/codegen/contracts.ts @@ -968,18 +968,22 @@ const codegenComponentAnchor = codeAnchor({ componentAnchorId("component:protocol.notation"), componentAnchorId("component:protocol.validate"), ], + layer: "application", + context: "protocol", }); const executableContractsAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.executable-contracts"), label: "derives step and example-space contracts from the graph", satisfies: ref("spec:extraction.executable-contracts"), component: componentAnchorId("component:protocol.codegen"), + role: "projection", }); const runnableModulesAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.runnable-modules"), label: "derives and publishes runnable registrar modules from the graph", satisfies: ref("spec:extraction.runnable-modules"), component: componentAnchorId("component:protocol.codegen"), + role: "projection", }); void codegenComponentAnchor; void executableContractsAnchor; diff --git a/src/extract/anchors.ts b/src/extract/anchors.ts index f52f8dc1..a6bd39fa 100644 --- a/src/extract/anchors.ts +++ b/src/extract/anchors.ts @@ -1,12 +1,14 @@ -import { Node, VariableDeclarationKind } from "ts-morph"; +import { Node, VariableDeclarationKind, ts } from "ts-morph"; import type { CallExpression, ObjectLiteralExpression, SourceFile } from "ts-morph"; import { CODE_ANCHOR_NAMESPACES } from "../ids.js"; import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; +import { CODE_ANCHOR_LAYERS } from "../model/anchors.js"; import { codeAnchor } from "../model/code-anchor.js"; import type { Finding, Severity } from "../validate/contracts.js"; import { graphValidatorIds } from "../validate/validators.js"; import { + checkIdText, duplicatePropertyMessage, extractFindingIds, peekId, @@ -24,37 +26,57 @@ import type { IdReification } 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. + * reads the two representations of an anchor and nothing else. The **constant form** is a + * top-level `const` initialized with a `codeAnchor(…)`/`specTest(…)`/`specOracle(…)` call bound + * to a Protocol import. The **comment form** (`spec:decisions.anchor-comment-form`) is any + * top-level `/** … *\/` block carrying reserved `@sdp…` tags; it needs no import. Both feed one + * closed envelope, so identical content yields identical graph data. * - * 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. + * An anchor is almost all envelope: the id, the binding targets, and the structural attributes + * 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", - specOracle: "models", +type AnchorFlavor = "code" | "test" | "oracle"; + +const ANCHOR_BUILDER_FLAVORS = { + codeAnchor: "code", + specTest: "test", + specOracle: "oracle", } as const; -const anchorExtractionAnchor = codeAnchor({ - id: codeAnchorId("impl:protocol.anchor-extraction"), - label: "anchor-constant reification seam", - satisfies: ref("spec:model.anchors"), - component: componentAnchorId("component:protocol.extract"), -}); +type AnchorBuilderName = keyof typeof ANCHOR_BUILDER_FLAVORS; -void anchorExtractionAnchor; +const FLAVOR_TARGET_FIELDS: Record = { + code: "satisfies", + test: "verifies", + oracle: "models", +}; -type AnchorBuilderName = keyof typeof ANCHOR_BUILDER_TARGET_FIELDS; +const FLAVOR_ID_NAMESPACES: Record = { + code: CODE_ANCHOR_NAMESPACES, + test: ["test"], + oracle: ["oracle"], +}; -const ANCHOR_ID_NAMESPACES: Record = { - codeAnchor: CODE_ANCHOR_NAMESPACES, - specTest: ["test"], - specOracle: ["oracle"], +const ANCHOR_ID_NAMESPACES: readonly string[] = [ + ...FLAVOR_ID_NAMESPACES.code, + ...FLAVOR_ID_NAMESPACES.test, + ...FLAVOR_ID_NAMESPACES.oracle, +]; + +/** The closed envelope per flavor, in the order the contract message lists it. */ +const FLAVOR_CONTRACTS: Record = { + code: ["id", "satisfies", "label", "component", "uses", "references", "role", "layer", "context"], + test: ["id", "verifies", "label"], + oracle: ["id", "models", "label"], }; +/** The fields a `component:` anchor alone may carry (the architectural annotation decision). */ +const COMPONENT_ONLY_FIELDS: ReadonlySet = new Set(["layer", "context"]); + +const CODE_ANCHOR_LAYER_SET: ReadonlySet = new Set(CODE_ANCHOR_LAYERS); + /** Every protocol authoring builder, for the misplaced-call scan (§1.3 of the Slice-2 plan). */ const AUTHORING_BUILDER_NAMES = new Set([ "spec", @@ -64,12 +86,56 @@ const AUTHORING_BUILDER_NAMES = new Set([ "specOracle", ]); +/** + * The comment-form line grammar (`spec:decisions.anchor-comment-form`): one TSDoc-compatible tag + * per line, read off the raw comment text after the delimiters are stripped, never through the + * TypeScript JSDoc tag parser. Every line that opens with `@sdp` is a reserved tag line; the known + * tags map onto the envelope fields, and an unknown or misspelled one (`@sdp-anchor`, + * `@sdpAnchor:`) is refused, never read as prose. A tag with no value still matches, so it can be + * refused loudly rather than ignored. + */ +const COMMENT_LINE_BODY = /^\s*\*?\s*(.*?)\s*$/u; +const COMMENT_TAG = /^@([A-Za-z][A-Za-z0-9]*)(?:\s+(.*))?$/u; +const RESERVED_TAG_PREFIX = "sdp"; +const COMMENT_OPEN = "/**"; +const COMMENT_CLOSE = "*/"; + +/** The reserved tags, each the envelope field it carries. */ +const COMMENT_TAG_FIELDS: ReadonlyMap = new Map([ + ["sdpAnchor", "id"], + ["sdpLabel", "label"], + ["sdpSatisfies", "satisfies"], + ["sdpVerifies", "verifies"], + ["sdpModels", "models"], + ["sdpReferences", "references"], + ["sdpComponent", "component"], + ["sdpUses", "uses"], + ["sdpRole", "role"], + ["sdpLayer", "layer"], + ["sdpContext", "context"], +]); + +/** `role` and `context` are one token each: a free vocabulary, but never free-form prose. */ +const STRUCTURAL_TOKEN = /^[a-z][a-z0-9-]*$/u; + +const anchorExtractionAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.anchor-extraction"), + label: "anchor reification seam: the constant form and the comment form", + satisfies: ref("spec:model.anchors"), + component: componentAnchorId("component:protocol.extract"), + references: [ref("spec:decisions.anchor-comment-form")], + role: "extractor", +}); + +void anchorExtractionAnchor; + export interface ReifiedAnchor { /** Plain anchor-shaped data (`CodeAnchor`/`SpecTestAnchor`/`SpecOracleAnchor`) — built from the - * AST, never evaluated. */ + * AST or the comment text, never evaluated. Binding targets (`satisfies`, `verifies`) are + * always lists here, whichever shape the author wrote. */ readonly data: Record; readonly id: string; - readonly flavor: "code" | "test" | "oracle"; + readonly flavor: AnchorFlavor; readonly file: string; readonly line: number; } @@ -103,31 +169,419 @@ function createAnchorFinding( } function isAnchorBuilderName(builder: string): builder is AnchorBuilderName { - return builder in ANCHOR_BUILDER_TARGET_FIELDS; + return builder in ANCHOR_BUILDER_FLAVORS; } -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 namespaceOf(id: string): string { + return id.slice(0, id.indexOf(":")); +} + +function flavorOfNamespace(namespace: string): AnchorFlavor | undefined { + return FLAVOR_ID_NAMESPACES.code.includes(namespace) + ? "code" + : FLAVOR_ID_NAMESPACES.test.includes(namespace) + ? "test" + : FLAVOR_ID_NAMESPACES.oracle.includes(namespace) + ? "oracle" + : undefined; +} + +type IdFailure = Exclude; + +/* ----- the one envelope, fed by two field readers ----- */ + +/** + * A field's value as one representation offers it. The constant form reads AST nodes (id builders + * unwrap in id slots, structural ids must come through their builders, lists are fresh array + * literals); the comment form reads tag text (lists split on commas). Each reader reports its own + * failures through the envelope and returns `undefined` when the field cannot be kept. + */ +interface FieldReader { + /** One id. `structuralBuilders` names the builders a structural slot insists on. */ + id( + expectedNamespaces: readonly string[], + path: string, + structuralBuilders?: readonly string[], + ): string | undefined; + /** A list of ids; the whole list fails when its shape or any element fails. */ + idList( + expectedNamespaces: readonly string[], + path: string, + structuralBuilders?: readonly string[], + ): readonly string[] | undefined; + /** One id or a list of ids, normalized to a list (`satisfies` / `verifies`). */ + idOrList(expectedNamespaces: readonly string[], path: string): readonly string[] | undefined; + /** A static string. Degradable fields drop with a warning; the rest fail the envelope. */ + text(path: string, degradable: boolean): string | undefined; } +class AnchorEnvelope { + readonly data: Record = {}; + readonly authoredNames = new Set(); + readonly authoredLines = new Map(); + sawOpaqueEntry = false; + envelopeOk = true; + + constructor( + readonly flavor: AnchorFlavor, + readonly file: string, + readonly siteLine: number, + readonly subjectId: string | undefined, + readonly findings: Finding[], + ) {} + + failEnvelope(line: number, message: string, path?: string): void { + this.findings.push( + createAnchorFinding( + extractFindingIds.nonStaticEnvelope, + "error", + message, + this.file, + line, + this.subjectId, + path, + ), + ); + this.envelopeOk = false; + } + + failStructural(line: number, message: string, path: string, relatedId?: string): void { + this.findings.push({ + ...createAnchorFinding( + graphValidatorIds.structuralAnchors, + "error", + message, + this.file, + line, + this.subjectId, + path, + ), + ...(relatedId === undefined ? {} : { relatedId }), + }); + this.envelopeOk = false; + } + + failId(failure: IdFailure, path: string): void { + this.findings.push( + createAnchorFinding( + failure.kind === "non-static" + ? extractFindingIds.nonStaticEnvelope + : extractFindingIds.invalidId, + "error", + `anchor field "${path}" did not reify: ${failure.reason}`, + this.file, + failure.line, + this.subjectId, + path, + ), + ); + this.envelopeOk = false; + } + + dropDetail(line: number, path: string, reason: string): void { + this.findings.push( + createAnchorFinding( + extractFindingIds.nonStaticSection, + "warning", + `property "${path}" dropped: at "${path}", ${reason}`, + this.file, + line, + this.subjectId, + path, + ), + ); + } + + /** Records an authored name; a repeat is ambiguity (L2) and fails the envelope. */ + markAuthored(name: string, line: number): boolean { + if (this.authoredNames.has(name)) { + this.failEnvelope(line, duplicatePropertyMessage(name), name); + return false; + } + + this.authoredNames.add(name); + this.authoredLines.set(name, line); + return true; + } + + private contractMessage(name: string): string { + return `anchor field "${name}" is outside the binding contract (${FLAVOR_CONTRACTS[this.flavor].join(" · ")}) — an anchor asserts a binding only, never system-truth content`; + } + + /** One authored field, dispatched by name under the flavor's closed envelope. */ + field(name: string, line: number, reader: FieldReader): void { + if (!this.markAuthored(name, line)) { + return; + } + + const targetField = FLAVOR_TARGET_FIELDS[this.flavor]; + + if (name === "id") { + const id = reader.id(FLAVOR_ID_NAMESPACES[this.flavor], "id"); + + if (id !== undefined) { + this.data.id = id; + } + return; + } + + if (name === targetField) { + if (this.flavor === "oracle") { + const id = reader.id(["spec"], name); + + if (id !== undefined) { + this.data[name] = id; + } + return; + } + + const targets = reader.idOrList(["spec"], name); + + if (targets !== undefined) { + this.data[name] = targets; + } + return; + } + + if (name === "label") { + const label = reader.text("label", true); + + if (label !== undefined) { + this.data.label = label; + } + return; + } + + if (this.flavor === "code") { + switch (name) { + case "component": { + const id = reader.id(["component"], "component", ["componentAnchorId"]); + + if (id !== undefined) { + this.data.component = id; + } + return; + } + case "uses": { + const ids = reader.idList(CODE_ANCHOR_NAMESPACES, "uses", [ + "codeAnchorId", + "componentAnchorId", + ]); + + if (ids !== undefined) { + this.data.uses = ids; + } + return; + } + case "references": { + const ids = reader.idList(["spec"], "references"); + + if (ids !== undefined) { + this.data.references = ids; + } + return; + } + case "role": + case "context": { + const value = reader.text(name, false); + + if (value === undefined) { + return; + } + + if (!STRUCTURAL_TOKEN.test(value)) { + this.failEnvelope( + line, + `anchor field "${name}" reified to "${value}", which is not one token of the form ${STRUCTURAL_TOKEN.source} — role and context are vocabulary, never prose`, + name, + ); + return; + } + + this.data[name] = value; + return; + } + case "layer": { + const value = reader.text("layer", false); + + if (value === undefined) { + return; + } + + if (!CODE_ANCHOR_LAYER_SET.has(value)) { + this.failEnvelope( + line, + `anchor field "layer" reified to "${value}", which is not one of ${CODE_ANCHOR_LAYERS.join(" · ")} — the layer set is closed`, + "layer", + ); + return; + } + + this.data.layer = value; + return; + } + default: + break; + } + } + + // 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. + this.failEnvelope(line, this.contractMessage(name), name); + } + + private lineOf(name: string): number { + return this.authoredLines.get(name) ?? this.siteLine; + } + + private checkTargetList(name: string): void { + const targets = this.data[name]; + + if (!Array.isArray(targets)) { + return; + } + + const list = targets as readonly string[]; + + if (list.length === 0) { + this.failEnvelope( + this.lineOf(name), + `anchor field "${name}" must name at least one target when present`, + name, + ); + } + + const seen = new Set(); + + for (const target of list) { + if (seen.has(target)) { + this.failEnvelope( + this.lineOf(name), + `anchor target "${target}" is authored more than once in "${name}" (ambiguity is loud, L2); one edge per target, so the anchor is not extracted`, + name, + ); + continue; + } + + seen.add(target); + } + } + + private checkStructuralList(name: string, describe: string): void { + const targets = this.data[name]; + + if (!Array.isArray(targets)) { + return; + } + + const list = targets as readonly string[]; + + if (list.length === 0) { + this.failStructural( + this.lineOf(name), + `anchor field "${name}" must be non-empty when present`, + name, + ); + } + + const seen = new Set(); + + for (const target of list) { + if (seen.has(target)) { + this.failStructural( + this.lineOf(name), + `${describe} target "${target}" is authored more than once; targets must be unique.`, + name, + target, + ); + continue; + } + + seen.add(target); + } + } + + private checkCodeStructure(): void { + this.checkStructuralList("uses", "Structural uses"); + this.checkStructuralList("references", "References"); + + const satisfies = Array.isArray(this.data.satisfies) + ? new Set(this.data.satisfies as readonly string[]) + : new Set(); + const references = Array.isArray(this.data.references) + ? (this.data.references as readonly string[]) + : []; + + for (const target of references) { + if (satisfies.has(target)) { + this.failStructural( + this.lineOf("references"), + `Spec "${target}" is named in both "satisfies" and "references"; a realized target is not also a design reference.`, + "references", + target, + ); + } + } + + // `layer` and `context` describe the declared component (v0 03 §2.5); on any other code + // anchor they are foreign fields. Judged on authored names, so a value that failed to reify + // is still called out once, and only when the id resolved to a non-component namespace. + const id = this.data.id; + + if (typeof id !== "string" || namespaceOf(id) === "component") { + return; + } + + for (const name of COMPONENT_ONLY_FIELDS) { + if (this.authoredNames.has(name)) { + this.failEnvelope( + this.lineOf(name), + `anchor field "${name}" belongs to a component: anchor only; "${id}" declares no component`, + name, + ); + } + } + } + + /** The closing pass: structural law, then absence, judged on authored names only. */ + finish(): ReifiedAnchor | undefined { + if (this.flavor === "code") { + this.checkTargetList("satisfies"); + this.checkCodeStructure(); + } else if (this.flavor === "test") { + this.checkTargetList("verifies"); + } + + const required = this.flavor === "code" ? ["id"] : ["id", FLAVOR_TARGET_FIELDS[this.flavor]]; + + for (const name of required) { + if (!this.authoredNames.has(name) && !this.sawOpaqueEntry) { + this.failEnvelope( + this.siteLine, + `anchor field "${name}" is missing — the binding cannot be constructed without it`, + name, + ); + } + } + + if (!this.envelopeOk) { + return undefined; + } + + return { + data: this.data, + id: this.data.id as string, + flavor: this.flavor, + file: this.file, + line: this.siteLine, + }; + } +} + +/* ----- the constant form ----- */ + function reifyStructuralId( node: Node, allowedBuilders: readonly string[], @@ -149,6 +603,107 @@ function reifyStructuralId( return reifyStaticIdExpression(node, expectedNamespaces, bindings, path); } +function constantFieldReader( + initializer: Node, + bindings: ProtocolBindings, + envelope: AnchorEnvelope, +): FieldReader { + const readId = ( + node: Node, + expectedNamespaces: readonly string[], + path: string, + structuralBuilders?: readonly string[], + ): string | undefined => { + const result = + structuralBuilders === undefined + ? reifyStaticIdExpression(node, expectedNamespaces, bindings, path) + : reifyStructuralId(node, structuralBuilders, expectedNamespaces, bindings, path); + + if (!result.ok) { + envelope.failId(result, path); + return undefined; + } + + return result.id; + }; + + const readElements = ( + elements: readonly Node[], + expectedNamespaces: readonly string[], + path: string, + structuralBuilders?: readonly string[], + ): readonly string[] | undefined => { + const ids: string[] = []; + let ok = true; + + for (const [position, element] of elements.entries()) { + const id = readId( + element, + expectedNamespaces, + `${path}[${String(position)}]`, + structuralBuilders, + ); + + if (id === undefined) { + ok = false; + continue; + } + + ids.push(id); + } + + return ok ? ids : undefined; + }; + + return { + id: (expectedNamespaces, path, structuralBuilders) => + readId(initializer, expectedNamespaces, path, structuralBuilders), + idList: (expectedNamespaces, path, structuralBuilders) => { + const unwrapped = unwrapTransparent(initializer); + + if (!Node.isArrayLiteralExpression(unwrapped)) { + envelope.failEnvelope( + initializer.getStartLineNumber(), + `anchor field "${path}" must be a fresh array literal of ${structuralBuilders === undefined ? "ref(…)" : structuralBuilders.map((name) => `${name}(…)`).join(" or ")} references`, + path, + ); + return undefined; + } + + return readElements(unwrapped.getElements(), expectedNamespaces, path, structuralBuilders); + }, + idOrList: (expectedNamespaces, path) => { + const unwrapped = unwrapTransparent(initializer); + + if (Node.isArrayLiteralExpression(unwrapped)) { + return readElements(unwrapped.getElements(), expectedNamespaces, path); + } + + const id = readId(unwrapped, expectedNamespaces, path); + return id === undefined ? undefined : [id]; + }, + text: (path, degradable) => { + const result = reifyStaticString(initializer, path); + + if (result.ok) { + return result.value as string; + } + + if (degradable) { + envelope.dropDetail(result.failure.line, path, result.failure.reason); + } else { + envelope.failEnvelope( + result.failure.line, + `anchor field "${path}" did not reify: ${result.failure.reason}`, + path, + ); + } + + return undefined; + }, + }; +} + function reifyAnchorCall( call: CallExpression, builder: AnchorBuilderName, @@ -156,6 +711,7 @@ function reifyAnchorCall( bindings: ProtocolBindings, findings: Finding[], ): ReifiedAnchor | undefined { + const flavor = ANCHOR_BUILDER_FLAVORS[builder]; const callArguments = call.getArguments(); const [firstArgument] = callArguments; let objectLiteral: ObjectLiteralExpression | undefined; @@ -182,61 +738,20 @@ function reifyAnchorCall( 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(); - const authoredLines = new Map(); - 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; - }; - - const failStructural = ( - line: number, - message: string, - path: string, - relatedId?: string, - ): void => { - findings.push({ - ...createAnchorFinding( - graphValidatorIds.structuralAnchors, - "error", - message, - file, - line, - subjectId, - path, - ), - ...(relatedId === undefined ? {} : { relatedId }), - }); - envelopeOk = false; - }; + const subjectId = peekId(objectLiteral, FLAVOR_ID_NAMESPACES[flavor], bindings); + const envelope = new AnchorEnvelope(flavor, file, call.getStartLineNumber(), subjectId, findings); 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()); + envelope.authoredNames.add(property.getName()); } else { - sawOpaqueEntry = true; + envelope.sawOpaqueEntry = true; } - failEnvelope( + envelope.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)", ); @@ -246,216 +761,434 @@ function reifyAnchorCall( 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); + envelope.sawOpaqueEntry = true; + envelope.failEnvelope( + property.getStartLineNumber(), + "computed property names are non-static", + ); continue; } - authoredNames.add(name); - authoredLines.set(name, property.getStartLineNumber()); const initializer = property.getInitializer(); if (initializer === undefined) { - failEnvelope( - property.getStartLineNumber(), - `property "${name}" carries no initializer`, - name, - ); + if (envelope.markAuthored(name, property.getStartLineNumber())) { + envelope.failEnvelope( + property.getStartLineNumber(), + `property "${name}" carries no initializer`, + name, + ); + } continue; } - if (name === "id") { - const idResult = reifyStaticIdExpression( - initializer, - ANCHOR_ID_NAMESPACES[builder], - bindings, - "id", + envelope.field( + name, + property.getStartLineNumber(), + constantFieldReader(initializer, bindings, envelope), + ); + } + + return envelope.finish(); +} + +/* ----- the comment form ----- */ + +interface CommentTag { + readonly tag: string; + readonly value: string | undefined; + readonly line: number; + /** A line that opens with `@sdp` but is not spelled as a tag: refused, never prose. */ + readonly malformed: boolean; +} + +interface CommentBlock { + /** The block's first line: the binding site. */ + readonly line: number; + readonly tags: readonly CommentTag[]; + /** Non-empty lines after the first reserved tag that do not open a tag: refused continuations. */ + readonly continuations: readonly number[]; +} + +function commentFieldReader(tag: CommentTag, value: string, envelope: AnchorEnvelope): FieldReader { + const readId = ( + text: string, + expectedNamespaces: readonly string[], + path: string, + ): string | undefined => { + const checked = checkIdText(text, expectedNamespaces); + + if (!checked.ok) { + envelope.failId({ ok: false, kind: "invalid", line: tag.line, reason: checked.reason }, path); + return undefined; + } + + return checked.id; + }; + + const readOne = (expectedNamespaces: readonly string[], path: string): string | undefined => { + if (value.includes(",")) { + envelope.failEnvelope( + tag.line, + `tag "@${tag.tag}" names more than one target; exactly one is required`, + path, ); + return undefined; + } + + return readId(value, expectedNamespaces, path); + }; - if (!idResult.ok) { - appendAnchorIdFinding(idResult, file, subjectId, "id", findings); - envelopeOk = false; + const readList = ( + expectedNamespaces: readonly string[], + path: string, + ): readonly string[] | undefined => { + const ids: string[] = []; + let ok = true; + + for (const [position, entry] of value.split(",").entries()) { + const text = entry.trim(); + const entryPath = `${path}[${String(position)}]`; + + if (text.length === 0) { + envelope.failEnvelope( + tag.line, + `tag "@${tag.tag}" carries an empty list item at "${entryPath}"`, + entryPath, + ); + ok = false; continue; } - data.id = idResult.id; - continue; - } - - if (name === targetField) { - const idResult = reifyStaticIdExpression(initializer, ["spec"], bindings, targetField); + const id = readId(text, expectedNamespaces, entryPath); - if (!idResult.ok) { - appendAnchorIdFinding(idResult, file, subjectId, targetField, findings); - envelopeOk = false; + if (id === undefined) { + ok = false; continue; } - data[targetField] = idResult.id; - continue; + ids.push(id); } - if (builder === "codeAnchor" && name === "component") { - const idResult = reifyStructuralId( - initializer, - ["componentAnchorId"], - ["component"], - bindings, - "component", - ); + return ok ? ids : undefined; + }; - if (!idResult.ok) { - appendAnchorIdFinding(idResult, file, subjectId, "component", findings); - envelopeOk = false; - continue; - } + return { + id: readOne, + idList: readList, + idOrList: readList, + text: () => value, + }; +} - data.component = idResult.id; +/** + * One `/** … *\/` block reduced to its reserved tag lines. A line that opens with `@sdp` is a + * reserved tag line wherever it stands, so a misspelled tag is refused rather than read as prose. + * Prose may precede the first reserved tag; after it, every non-empty line must open a tag + * (`@…`), so an accidentally wrapped target is refused rather than read as prose. Other TSDoc + * tags (`@param`, `@returns`) are not read. + */ +function readCommentBlock(text: string, line: number): CommentBlock | undefined { + const body = text.slice(COMMENT_OPEN.length, -COMMENT_CLOSE.length); + const tags: CommentTag[] = []; + const continuations: number[] = []; + + for (const [offset, rawLine] of body.split(/\r?\n/u).entries()) { + const content = COMMENT_LINE_BODY.exec(rawLine)?.[1] ?? ""; + const tagMatch = COMMENT_TAG.exec(content); + + if (content.startsWith(`@${RESERVED_TAG_PREFIX}`)) { + const value = tagMatch?.[2]; + tags.push( + tagMatch?.[1] === undefined + ? { + tag: (content.split(/\s/u, 1)[0] ?? content).slice(1), + value: undefined, + line: line + offset, + malformed: true, + } + : { + tag: tagMatch[1], + value: value === undefined || value.length === 0 ? undefined : value, + line: line + offset, + malformed: false, + }, + ); continue; } - if (builder === "codeAnchor" && name === "uses") { - const unwrapped = unwrapTransparent(initializer); + if (tagMatch !== null || tags.length === 0) { + continue; + } - if (!Node.isArrayLiteralExpression(unwrapped)) { - failEnvelope( - initializer.getStartLineNumber(), - 'anchor field "uses" must be a fresh array literal of codeAnchorId(…) references', - "uses", - ); - continue; - } + if (content.length > 0) { + continuations.push(line + offset); + } + } - const targets: string[] = []; - let targetsOk = true; + return tags.length === 0 ? undefined : { line, tags, continuations }; +} - for (const [position, element] of unwrapped.getElements().entries()) { - const path = `uses[${String(position)}]`; - const idResult = reifyStructuralId( - element, - ["codeAnchorId", "componentAnchorId"], - CODE_ANCHOR_NAMESPACES, - bindings, - path, - ); +function malformedTagMessage(tag: CommentTag): string { + return `malformed reserved tag "@${tag.tag}": a line that opens with @sdp is a reserved tag, written as @sdp and a camelCase name, then whitespace and its value`; +} - if (!idResult.ok) { - appendAnchorIdFinding(idResult, file, subjectId, path, findings); - envelopeOk = false; - targetsOk = false; - continue; - } +function isCommentBlock(text: string): boolean { + return ( + text.startsWith(COMMENT_OPEN) && + text.endsWith(COMMENT_CLOSE) && + text.length >= COMMENT_OPEN.length + COMMENT_CLOSE.length + ); +} - targets.push(idResult.id); - } +function reifyCommentBlock( + block: CommentBlock, + file: string, + findings: Finding[], +): ReifiedAnchor | undefined { + const opener = block.tags.find((tag) => tag.tag === "sdpAnchor"); + + // The flavor is the opener's namespace; without it no other line can be judged, and the block + // is refused with the one finding the constant form gives a missing id. + if (opener === undefined) { + for (const tag of block.tags.filter((candidate) => candidate.malformed)) { + findings.push( + createAnchorFinding( + extractFindingIds.nonStaticEnvelope, + "error", + malformedTagMessage(tag), + file, + tag.line, + undefined, + tag.tag, + ), + ); + } + findings.push( + createAnchorFinding( + extractFindingIds.nonStaticEnvelope, + "error", + 'anchor field "id" is missing — a comment block carrying @sdp… tags must open its anchor with @sdpAnchor ', + file, + block.line, + undefined, + "id", + ), + ); + return undefined; + } - if (targetsOk) { - data.uses = targets; - } + const opened = + opener.value === undefined + ? { ok: false as const, reason: 'tag "@sdpAnchor" carries no id' } + : opener.value.includes(",") + ? { ok: false as const, reason: 'tag "@sdpAnchor" names more than one id' } + : checkIdText(opener.value, ANCHOR_ID_NAMESPACES); + + if (!opened.ok) { + findings.push( + createAnchorFinding( + extractFindingIds.invalidId, + "error", + `anchor field "id" did not reify: ${opened.reason}`, + file, + opener.line, + undefined, + "id", + ), + ); + return undefined; + } + + const flavor = flavorOfNamespace(namespaceOf(opened.id)); + + if (flavor === undefined) { + return undefined; + } + + const envelope = new AnchorEnvelope(flavor, file, block.line, opened.id, findings); + + for (const tag of block.tags) { + if (tag.malformed) { + envelope.failEnvelope(tag.line, malformedTagMessage(tag), tag.tag); 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; - } + const name = COMMENT_TAG_FIELDS.get(tag.tag); - data.label = result.value; + if (name === undefined) { + envelope.failEnvelope( + tag.line, + `tag "@${tag.tag}" is not a reserved anchor tag (${[...COMMENT_TAG_FIELDS.keys()].map((known) => `@${known}`).join(" · ")}) — the envelope is closed`, + tag.tag, + ); 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${builder === "codeAnchor" ? " · component · uses" : ""}) — an anchor asserts a binding only, never system-truth content`, - name, - ); + if (tag.value === undefined) { + if (envelope.markAuthored(name, tag.line)) { + envelope.failEnvelope(tag.line, `tag "@${tag.tag}" carries no value`, name); + } + continue; + } + + envelope.field(name, tag.line, commentFieldReader(tag, tag.value, envelope)); } - if (builder === "codeAnchor") { - const uses = Array.isArray(data.uses) ? (data.uses as readonly string[]) : undefined; + for (const line of block.continuations) { + envelope.failEnvelope( + line, + "a line after the first reserved tag must open a tag — a wrapped target is refused, never read as prose", + ); + } - if (uses !== undefined) { - if (uses.length === 0) { - failStructural( - authoredLines.get("uses") ?? call.getStartLineNumber(), - 'anchor field "uses" must be non-empty when present', - "uses", - ); - } + return envelope.finish(); +} - const seen = new Set(); - - for (const target of uses) { - if (seen.has(target)) { - failStructural( - authoredLines.get("uses") ?? call.getStartLineNumber(), - `Structural uses target "${target}" is authored more than once; targets must be unique.`, - "uses", - target, - ); - continue; - } +interface TopLevelComment { + readonly pos: number; + readonly text: string; + readonly line: number; +} - seen.add(target); +/** + * Every top-level comment of a file, in source order: the leading and trailing comments of each + * top-level statement, then the comments after the last statement (the end-of-file token's + * leading trivia, which is all of a comment-only file). Attachment to a declaration is not + * recorded: a block above the first import is simply an anchor in that file. + */ +function topLevelComments(sourceFile: SourceFile): readonly TopLevelComment[] { + const seen = new Set(); + const comments: TopLevelComment[] = []; + const collect = (ranges: readonly { getPos(): number; getText(): string }[]): void => { + for (const range of ranges) { + if (seen.has(range.getPos())) { + continue; } + + seen.add(range.getPos()); + comments.push({ + pos: range.getPos(), + text: range.getText(), + line: sourceFile.getLineAndColumnAtPos(range.getPos()).line, + }); } + }; + + for (const statement of sourceFile.getStatements()) { + collect(statement.getLeadingCommentRanges()); + collect(statement.getTrailingCommentRanges()); } - // 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, - ); + // The end-of-file token is not a ts-morph node; its leading trivia holds every comment after + // the last statement, which is the whole of a comment-only file. + const fullText = sourceFile.getFullText(); + collect( + (ts.getLeadingCommentRanges(fullText, sourceFile.compilerNode.endOfFileToken.pos) ?? []).map( + (range) => ({ + getPos: () => range.pos, + getText: () => fullText.slice(range.pos, range.end), + }), + ), + ); + + return comments.sort((left, right) => left.pos - right.pos); +} + +function isJsDocNode(node: ts.Node): boolean { + return node.kind >= ts.SyntaxKind.FirstJSDocNode && node.kind <= ts.SyntaxKind.LastJSDocNode; +} + +/** + * Every comment of a file, in source order. A comment sits in the trivia between two tokens, as + * the leading trivia of the next token or the trailing trivia of the one before, so a walk over + * every token, closing delimiters and the end-of-file token included, misses none: not one before + * the closing brace of an empty body, not one in an empty JSX expression. JSX text and a doc + * comment's own content are not trivia, so neither is scanned for comments. + */ +function everyComment(sourceFile: SourceFile): readonly TopLevelComment[] { + const root = sourceFile.compilerNode; + const fullText = root.getFullText(); + const ranges = new Map(); + const jsxText: { readonly pos: number; readonly end: number }[] = []; + const visit = (node: ts.Node): void => { + if (isJsDocNode(node)) { + return; } - } - if (!envelopeOk) { - return undefined; - } + if (node.kind === ts.SyntaxKind.JsxText) { + jsxText.push({ pos: node.pos, end: node.end }); + return; + } - return { - data, - id: data.id as string, - flavor: builder === "codeAnchor" ? "code" : builder === "specTest" ? "test" : "oracle", - file, - line: call.getStartLineNumber(), + for (const range of [ + ...(ts.getLeadingCommentRanges(fullText, node.pos) ?? []), + ...(ts.getTrailingCommentRanges(fullText, node.end) ?? []), + ]) { + ranges.set(range.pos, range); + } + + for (const child of node.getChildren(root)) { + visit(child); + } }; + + visit(root); + return [...ranges.values()] + .filter((range) => !jsxText.some((text) => range.pos >= text.pos && range.pos < text.end)) + .sort((left, right) => left.pos - right.pos) + .map((range) => ({ + pos: range.pos, + text: fullText.slice(range.pos, range.end), + line: sourceFile.getLineAndColumnAtPos(range.pos).line, + })); +} + +/** + * A reserved tag in a nested position (a class body, a function body, an object literal, a JSX + * expression) is refused, never ignored: the author believes a binding exists (L2). A nested + * comment is judged by the grammar the top level reads: a `/** … *\/` block with a reserved tag + * line is refused, and a `//` or `/*` comment, or prose that mentions a tag, stays unread. + */ +function reportMisplacedReservedTags( + sourceFile: SourceFile, + topLevelPositions: ReadonlySet, + file: string, + findings: Finding[], +): void { + for (const comment of everyComment(sourceFile)) { + if ( + topLevelPositions.has(comment.pos) || + !isCommentBlock(comment.text) || + readCommentBlock(comment.text, comment.line) === undefined + ) { + continue; + } + + findings.push( + createAnchorFinding( + extractFindingIds.nonStaticEnvelope, + "error", + "misplaced reserved tag: an @sdp… tag binds only from a top-level /** … */ comment, never from a nested position", + file, + comment.line, + ), + ); + } } +/* ----- the file sweep ----- */ + /** - * 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. + * Reifies the anchors of one source file standalone — no type checker, no import following + * (static reification without execution, MD-14). The comment form is read off every top-level + * `/** … *\/` block whether or not the file imports anything, and a reserved tag anywhere else is + * refused; the constant form, and the misplaced-authoring scan, need a Protocol import binding. The scan warns loudly + * on a protocol authoring call outside its recognized surface (L2 — a binding the author believes + * exists must never silently fall out of the graph) and 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. */ export function reifyAnchorSourceFile( sourceFile: SourceFile, @@ -463,13 +1196,39 @@ export function reifyAnchorSourceFile( bindingScope?: ProtocolBindingScope, ): AnchorFileReification { const bindings = collectProtocolBindings(sourceFile, bindingScope); + const hasBindings = bindings.named.size > 0 || bindings.namespaceLocals.size > 0; + const anchors: ReifiedAnchor[] = []; + const findings: Finding[] = []; + + if (sourceFile.getFullText().includes(`@${RESERVED_TAG_PREFIX}`)) { + const comments = topLevelComments(sourceFile); - if (bindings.named.size === 0 && bindings.namespaceLocals.size === 0) { - return { anchors: [], findings: [] }; + for (const comment of comments) { + if (!isCommentBlock(comment.text)) { + continue; + } + + const block = readCommentBlock(comment.text, comment.line); + const reified = + block === undefined ? undefined : reifyCommentBlock(block, relativePath, findings); + + if (reified !== undefined) { + anchors.push(reified); + } + } + + reportMisplacedReservedTags( + sourceFile, + new Set(comments.map((comment) => comment.pos)), + relativePath, + findings, + ); + } + + if (!hasBindings) { + return { anchors, findings }; } - const anchors: ReifiedAnchor[] = []; - const findings: Finding[] = []; const recognizedCalls = new Set(); for (const statement of sourceFile.getStatements()) { diff --git a/src/extract/carrier.ts b/src/extract/carrier.ts index b1365a51..3fb4d76a 100644 --- a/src/extract/carrier.ts +++ b/src/extract/carrier.ts @@ -56,6 +56,7 @@ const carrierReificationAnchor = codeAnchor({ label: "reifies each canonical carrier surface into specs, packs, and findings", satisfies: ref("spec:extraction.derive-graph"), component: componentAnchorId("component:protocol.extract"), + role: "extractor", }); void carrierReificationAnchor; diff --git a/src/extract/derive.ts b/src/extract/derive.ts index 121410cf..00b9a11b 100644 --- a/src/extract/derive.ts +++ b/src/extract/derive.ts @@ -10,6 +10,8 @@ import type { PackNode, PrimitiveNode, } from "../graph/schema.js"; +import { CODE_ANCHOR_LAYERS } from "../model/anchors.js"; +import type { CodeAnchorLayer } from "../model/anchors.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"; @@ -94,6 +96,7 @@ const packMemberOrderAnchor = codeAnchor({ label: "keeps manifest member order on the Pack node", satisfies: ref("spec:extraction.pack-member-order"), component: componentAnchorId("component:protocol.extract"), + role: "extractor", }); void packMemberOrderAnchor; @@ -114,12 +117,30 @@ function derivePackNode(entry: ReifiedPack, memberIds: readonly string[]): PackN }; } +function isCodeAnchorLayer(value: unknown): value is CodeAnchorLayer { + return typeof value === "string" && CODE_ANCHOR_LAYERS.some((layer) => layer === value); +} + +function idList(value: unknown): readonly string[] { + if (typeof value === "string") { + return [value]; + } + + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + /** Test and oracle anchors both ride the `Anchor` node type — the id namespace (`test:` / * `oracle:`) and the binding edge type (`verifies` / `models`) carry the flavor. */ function deriveAnchorNode(entry: ReifiedAnchor): AnchorNode | CodeNode { const label = entry.data.label; if (entry.flavor === "code") { + const role = entry.data.role; + const layer = entry.data.layer; + const context = entry.data.context; + return { id: entry.id, nodeType: "CodeNode", @@ -127,6 +148,9 @@ function deriveAnchorNode(entry: ReifiedAnchor): AnchorNode | CodeNode { ...(typeof label === "string" ? { label } : {}), file: entry.file, line: entry.line, + ...(typeof role === "string" ? { role } : {}), + ...(isCodeAnchorLayer(layer) ? { layer } : {}), + ...(typeof context === "string" ? { context } : {}), }; } @@ -146,7 +170,7 @@ function deriveAnchorNode(entry: ReifiedAnchor): AnchorNode | CodeNode { * — the `spec:extraction.derive-graph` edge contract), one edge per authored relation, one derived `belongsTo` per * manifest entry, and anchored binding/structural edges per anchor. `memberOf` and `uses` connect * CodeNode endpoints only: they confer no delivery fact and deliberately do not join the reader's - * binding-to-Spec traversal. `belongsTo` is a deterministic re-expression of the declared manifest, + * binding-to-Spec traversal. `references` reaches a Spec but confers nothing either. `belongsTo` is a deterministic re-expression of the declared manifest, * so it inherits its source's claim — there is no 4th claim (`spec:extraction.claim-taxonomy`). 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 @@ -159,6 +183,8 @@ export const deriveGraphAnchor = codeAnchor({ satisfies: ref("spec:extraction.derive-graph"), component: componentAnchorId("component:protocol.extract"), uses: [codeAnchorId("impl:protocol.delivery-facts")], + references: [ref("spec:decisions.anchor-binding-grain")], + role: "extractor", }); export function deriveGraph( @@ -208,9 +234,10 @@ export function deriveGraph( const targetField = entry.flavor === "code" ? "satisfies" : entry.flavor === "test" ? "verifies" : "models"; - const target = entry.data[targetField]; - if (typeof target === "string") { + // One binding edge per target (the binding grain, `spec:decisions.anchor-binding-grain`): + // reified anchors carry lists, a typed `CodeAnchor` may still carry one id. + for (const target of idList(entry.data[targetField])) { edges.push({ from: entry.id, type: targetField, @@ -234,17 +261,24 @@ export function deriveGraph( }); } - const uses = Array.isArray(entry.data.uses) ? (entry.data.uses as readonly unknown[]) : []; - - for (const dependency of uses) { - if (typeof dependency === "string") { - edges.push({ - from: entry.id, - type: "uses", - to: dependency, - claim: "anchored", - }); - } + for (const dependency of idList(entry.data.uses)) { + edges.push({ + from: entry.id, + type: "uses", + to: dependency, + claim: "anchored", + }); + } + + // The non-conferring design reference: emitted as data like every other anchored edge, so a + // dangling target is referential integrity's sentinel and never a dropped binding. + for (const target of idList(entry.data.references)) { + edges.push({ + from: entry.id, + type: "references", + to: target, + claim: "anchored", + }); } } diff --git a/src/extract/discover.ts b/src/extract/discover.ts index 4cbdc58a..29f23bd0 100644 --- a/src/extract/discover.ts +++ b/src/extract/discover.ts @@ -37,6 +37,7 @@ const discoverFilesAnchor = codeAnchor({ label: "realizes exclusion-aware discovery (normalizeExcludes / discoverFiles)", satisfies: ref("spec:extraction.excludes"), component: componentAnchorId("component:protocol.extract"), + role: "extractor", }); void discoverFilesAnchor; diff --git a/src/extract/gherkin.ts b/src/extract/gherkin.ts index 7ecb794a..6df2fba9 100644 --- a/src/extract/gherkin.ts +++ b/src/extract/gherkin.ts @@ -1231,6 +1231,7 @@ export const gherkinAuthoringAnchor = codeAnchor({ label: "reifies Gherkin behavior and example authoring into the one carrier path", satisfies: ref("spec:carrier.gherkin-authoring"), component: componentAnchorId("component:protocol.extract"), + role: "extractor", }); export function reifyGherkinCarrier(sourceText: string, relativePath: string): CarrierReification { diff --git a/src/extract/index.ts b/src/extract/index.ts index 1c15b02f..76bdeb0d 100644 --- a/src/extract/index.ts +++ b/src/extract/index.ts @@ -19,7 +19,10 @@ import type { ReifiedPack, ReifiedSpec } from "./reify.js"; import { hasProtocolBuilderImport, protocolBindingScopeFor } from "./protocol-bindings.js"; import type { ProtocolBindingScope } from "./protocol-bindings.js"; -export { PROTOCOL_MODULE_SPECIFIER } from "./protocol-bindings.js"; +export { + PROTOCOL_ANCHORS_MODULE_SPECIFIER, + PROTOCOL_MODULE_SPECIFIER, +} from "./protocol-bindings.js"; export { extractFindingIds } from "./reify.js"; export { serializeGraph } from "./serialize.js"; @@ -41,6 +44,7 @@ const exclusionSurfaceAnchor = codeAnchor({ label: "strict root-relative exclusion input for both extraction surfaces", satisfies: ref("spec:extraction.excludes"), component: componentAnchorId("component:protocol.extract"), + role: "contract", }); void exclusionSurfaceAnchor; @@ -90,6 +94,7 @@ export const duplicateIdExclusionAnchor = codeAnchor({ label: "excludes duplicated carrier ids from the graph", satisfies: ref("spec:validation.duplicate-ids"), component: componentAnchorId("component:protocol.extract"), + role: "validator", }); function findDuplicatedIds( @@ -187,6 +192,8 @@ const extractComponentAnchor = codeAnchor({ componentAnchorId("component:protocol.model"), componentAnchorId("component:protocol.notation"), ], + layer: "application", + context: "protocol", }); void extractComponentAnchor; @@ -195,6 +202,7 @@ export const extractAnchor = codeAnchor({ label: "extracts authored carriers and bindings into one graph", satisfies: ref("spec:extraction.derive-graph"), component: componentAnchorId("component:protocol.extract"), + role: "extractor", }); export function extract(options: ExtractOptions): ExtractionResult { diff --git a/src/extract/markdown-body-owner-behavior.ts b/src/extract/markdown-body-owner-behavior.ts index 41cbc462..2e58b201 100644 --- a/src/extract/markdown-body-owner-behavior.ts +++ b/src/extract/markdown-body-owner-behavior.ts @@ -35,6 +35,7 @@ const openQuestionKeysAnchor = codeAnchor({ "reads an open question's optional key from its Markdown marker and refuses a bad or repeated key", satisfies: ref("spec:model.open-question-keys"), component: componentAnchorId("component:protocol.extract"), + role: "codec", }); void openQuestionKeysAnchor; diff --git a/src/extract/markdown-body.ts b/src/extract/markdown-body.ts index e8724d4d..f3da121f 100644 --- a/src/extract/markdown-body.ts +++ b/src/extract/markdown-body.ts @@ -120,6 +120,7 @@ const markdownBodyGrammarAnchor = codeAnchor({ label: "enforces the closed Markdown body grammar", satisfies: ref("spec:carrier.markdown-body-grammar"), component: componentAnchorId("component:protocol.extract"), + role: "codec", }); void markdownBodyGrammarAnchor; diff --git a/src/extract/markdown-inline-code.ts b/src/extract/markdown-inline-code.ts index b5d3217e..9fada865 100644 --- a/src/extract/markdown-inline-code.ts +++ b/src/extract/markdown-inline-code.ts @@ -6,6 +6,7 @@ const inlineCodeSpansAnchor = codeAnchor({ label: "scans raw HTML outside matched code spans", satisfies: ref("spec:carrier.inline-code-spans"), component: componentAnchorId("component:protocol.extract"), + role: "codec", }); void inlineCodeSpansAnchor; diff --git a/src/extract/markdown-pack.ts b/src/extract/markdown-pack.ts index 6127bff7..20950617 100644 --- a/src/extract/markdown-pack.ts +++ b/src/extract/markdown-pack.ts @@ -118,6 +118,7 @@ export const markdownPackAuthoringAnchor = codeAnchor({ label: "reifies the Markdown Pack manifest into the one carrier path", satisfies: ref("spec:carrier.markdown-pack-authoring"), component: componentAnchorId("component:protocol.extract"), + role: "extractor", }); export function reifyMarkdownPack(sourceText: string, relativePath: string): CarrierReification { try { diff --git a/src/extract/markdown.ts b/src/extract/markdown.ts index 99895510..207cd784 100644 --- a/src/extract/markdown.ts +++ b/src/extract/markdown.ts @@ -20,6 +20,7 @@ export const envelopeContractAnchor = codeAnchor({ label: "parses the bounded Markdown frontmatter envelope", satisfies: ref("spec:carrier.envelope-contract"), component: componentAnchorId("component:protocol.extract"), + role: "codec", }); export function parseMarkdownFrontmatter( @@ -34,6 +35,7 @@ export const proseOwnershipAnchor = codeAnchor({ label: "reads Markdown body content through its prose owners", satisfies: ref("spec:carrier.prose-ownership-rule"), component: componentAnchorId("component:protocol.extract"), + role: "codec", }); export function readMarkdownBody( @@ -52,6 +54,7 @@ export const markdownAuthoringAnchor = codeAnchor({ label: "reifies Markdown authoring into the one carrier path", satisfies: ref("spec:carrier.markdown-authoring"), component: componentAnchorId("component:protocol.extract"), + role: "extractor", }); export const markdownParserAnchor = codeAnchor({ @@ -59,6 +62,7 @@ export const markdownParserAnchor = codeAnchor({ label: "reifies the ruled Markdown parser input", satisfies: ref("spec:carrier.markdown-parser"), component: componentAnchorId("component:protocol.extract"), + role: "extractor", }); export function reifyMarkdownCarrier(sourceText: string, relativePath: string): CarrierReification { diff --git a/src/extract/protocol-bindings.ts b/src/extract/protocol-bindings.ts index 5a077170..d106faf1 100644 --- a/src/extract/protocol-bindings.ts +++ b/src/extract/protocol-bindings.ts @@ -10,6 +10,16 @@ import { codeAnchor } from "../model/code-anchor.js"; /** The one public package specifier whose imports bind Protocol authoring builders. */ export const PROTOCOL_MODULE_SPECIFIER = "@libar-dev/software-delivery-protocol"; +/** + * The zero-dependency anchor subpath (`spec:decisions.anchor-comment-form`): it ships the id and + * anchor builders alone, so a runtime that must not load `node:*` or ts-morph can still bind + * constant-form anchors, and the extractor trusts it exactly as it trusts the bare specifier. + */ +export const PROTOCOL_ANCHORS_MODULE_SPECIFIER = `${PROTOCOL_MODULE_SPECIFIER}/anchors`; + +/** The reserved comment-form tag prefix; a file carrying it is parsed whether or not it imports. */ +export const ANCHOR_COMMENT_TAG_PREFIX = "@sdp"; + export interface ProtocolBindings { /** Local name → exported builder name, from named imports. */ readonly named: ReadonlyMap; @@ -72,6 +82,7 @@ function trustedBuilderModules(): ReadonlySet { const candidates = [ join(packageRoot, "src", "ids.ts"), join(packageRoot, "src", "model", "code-anchor.ts"), + join(packageRoot, "src", "anchors.ts"), ]; return new Set(candidates.flatMap((candidate) => canonicalPath(candidate) ?? [])); @@ -84,6 +95,7 @@ const protocolBindingsAnchor = codeAnchor({ label: "Protocol builder-import trust (protocolBindingScopeFor / collectProtocolBindings)", satisfies: ref("spec:model.anchors"), component: componentAnchorId("component:protocol.extract"), + role: "extractor", }); void protocolBindingsAnchor; @@ -106,7 +118,7 @@ export function isProtocolBuilderModuleSpecifier( specifier: string, scope: ProtocolBindingScope = packageOnlyScope, ): boolean { - if (specifier === PROTOCOL_MODULE_SPECIFIER) { + if (specifier === PROTOCOL_MODULE_SPECIFIER || specifier === PROTOCOL_ANCHORS_MODULE_SPECIFIER) { return true; } @@ -123,11 +135,20 @@ export function isProtocolBuilderModuleSpecifier( }); } +/** + * The raw-text prefilter that gates AST work. The bare specifier is a prefix of the anchors + * subpath, so one `includes` admits both; the comment form needs no import, so the reserved tag + * prefix admits a file on its own (trust by reserved grammar, not by builder import) — a block + * with `@sdpReferences` and no `@sdpAnchor` still gets its diagnostic. + */ export function hasProtocolBuilderImport( sourceText: string, scope: ProtocolBindingScope = packageOnlyScope, ): boolean { - if (sourceText.includes(PROTOCOL_MODULE_SPECIFIER)) { + if ( + sourceText.includes(PROTOCOL_MODULE_SPECIFIER) || + sourceText.includes(ANCHOR_COMMENT_TAG_PREFIX) + ) { return true; } diff --git a/src/extract/reify.ts b/src/extract/reify.ts index ee8ff05b..037bb46c 100644 --- a/src/extract/reify.ts +++ b/src/extract/reify.ts @@ -430,6 +430,40 @@ function namespacesLabel(namespaces: readonly string[]): string { : `one of ${namespacesList(namespaces)} is required`; } +export type IdTextCheck = + | { readonly ok: true; readonly id: string } + | { readonly ok: false; readonly reason: string }; + +/** + * The id grammar check every id slot shares, over the bare text: `parseId` must accept it, its + * namespace must be one of the slot's, and it must not be an entry address. The constant form + * reaches it through `reifyStaticIdExpression`; the comment form reads tag text and calls it + * directly, so one grammar serves both representations of an anchor. + */ +export function checkIdText(idText: string, expectedNamespaces: readonly string[]): IdTextCheck { + try { + const parsed = parseId(idText); + + if (!expectedNamespaces.includes(parsed.namespace)) { + return { + ok: false, + reason: `id "${idText}" carries namespace "${parsed.namespace}" where ${namespacesLabel(expectedNamespaces)}`, + }; + } + + const addressReason = entryAddressSlotReason(idText, parsed); + + return addressReason === undefined + ? { ok: true, id: idText } + : { ok: false, reason: addressReason }; + } catch (error) { + return { + ok: false, + reason: error instanceof Error ? error.message : `id "${idText}" fails the id grammar`, + }; + } +} + /** * 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 @@ -471,48 +505,32 @@ export function reifyStaticIdExpression( 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) { + let namespace: string | undefined; - // 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`, - }; + try { + namespace = parseId(idText).namespace; + } catch { + namespace = undefined; } - if (!expectedNamespaces.includes(parsed.namespace)) { + if (namespace !== undefined && !builderNamespaces.includes(namespace)) { return { ok: false, kind: "invalid", line, - reason: `id "${idText}" carries namespace "${parsed.namespace}" where ${namespacesLabel(expectedNamespaces)}`, + reason: `id "${idText}" carries namespace "${namespace}" where ${builderCall.builder}(…) accepts only ${namespacesList(builderNamespaces)} — the builder's own contract, restated statically`, }; } - - const addressReason = entryAddressSlotReason(idText, parsed); - - if (addressReason !== undefined) { - return { ok: false, kind: "invalid", line, reason: addressReason }; - } - } 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 }; + const checked = checkIdText(idText, expectedNamespaces); + + return checked.ok + ? { ok: true, id: idText } + : { ok: false, kind: "invalid", line, reason: checked.reason }; } function reifyStaticValue(node: Node, path: string, bindings: ProtocolBindings): StaticResult { @@ -1912,6 +1930,7 @@ const staticReificationAnchor = codeAnchor({ label: "reifies authored carriers from the AST and never evaluates them", satisfies: ref("spec:extraction.determinism"), component: componentAnchorId("component:protocol.extract"), + role: "extractor", }); void staticReificationAnchor; diff --git a/src/extract/serialize.ts b/src/extract/serialize.ts index 5bdf6bb4..5b16acd5 100644 --- a/src/extract/serialize.ts +++ b/src/extract/serialize.ts @@ -100,6 +100,7 @@ const openSectionOrderAnchor = codeAnchor({ label: "preserves authored open-section and model-term order", satisfies: ref("spec:extraction.open-section-order"), component: componentAnchorId("component:protocol.extract"), + role: "codec", }); void openSectionOrderAnchor; @@ -225,6 +226,9 @@ function canonicalNode(node: GraphNode): Record { ...(node.label === undefined ? {} : { label: node.label }), file: node.file, ...(node.line === undefined ? {} : { line: node.line }), + ...(node.role === undefined ? {} : { role: node.role }), + ...(node.layer === undefined ? {} : { layer: node.layer }), + ...(node.context === undefined ? {} : { context: node.context }), }; } } diff --git a/src/graph/delivery-facts.ts b/src/graph/delivery-facts.ts index 65e0ed1d..e272e17b 100644 --- a/src/graph/delivery-facts.ts +++ b/src/graph/delivery-facts.ts @@ -46,6 +46,7 @@ const deliveryFactsAnchor = codeAnchor({ label: "computes delivery facts from resolving binding edges", satisfies: ref("spec:extraction.delivery-facts"), component: componentAnchorId("component:protocol.graph"), + role: "decider", }); void deliveryFactsAnchor; diff --git a/src/graph/example-space.ts b/src/graph/example-space.ts index 4bb86e8e..396068c3 100644 --- a/src/graph/example-space.ts +++ b/src/graph/example-space.ts @@ -119,6 +119,7 @@ const exampleSpaceAnchor = codeAnchor({ label: "resolves example-space vocabulary for contracts and floors", satisfies: ref("spec:extraction.executable-contracts"), component: componentAnchorId("component:protocol.graph"), + role: "decider", }); void exampleSpaceAnchor; diff --git a/src/graph/oracle-bindings.ts b/src/graph/oracle-bindings.ts index 86604e92..f6405bbb 100644 --- a/src/graph/oracle-bindings.ts +++ b/src/graph/oracle-bindings.ts @@ -27,6 +27,7 @@ const oracleTargetEligibilityAnchor = codeAnchor({ label: "resolves oracle targets by example-space ownership across Spec kinds", satisfies: ref("spec:validation.oracle-target-eligibility"), component: componentAnchorId("component:protocol.graph"), + role: "decider", }); void oracleTargetEligibilityAnchor; diff --git a/src/graph/schema.ts b/src/graph/schema.ts index 231d023f..68237cf5 100644 --- a/src/graph/schema.ts +++ b/src/graph/schema.ts @@ -1,15 +1,17 @@ import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; import { codeAnchor } from "../model/code-anchor.js"; +import type { CodeAnchorLayer } from "../model/anchors.js"; import type { SpecAltitude, SpecKind, SpecReadiness } from "../model/descriptors.js"; import type { SpecSections } from "../model/sections.js"; -export const schemaVersion = "0.7.0" as const; +export const schemaVersion = "0.8.0" as const; const schemaVersionAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.schema-version"), label: "declares the graph schema version", satisfies: ref("spec:extraction.schema-versioning"), component: componentAnchorId("component:protocol.graph"), + role: "contract", }); void schemaVersionAnchor; @@ -27,12 +29,15 @@ const graphComponentAnchor = codeAnchor({ componentAnchorId("component:protocol.model"), componentAnchorId("component:protocol.notation"), ], + layer: "domain", + context: "protocol", }); const graphClaimsAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.graph-claims"), label: "declares the graph claim taxonomy", satisfies: ref("spec:extraction.claim-taxonomy"), component: componentAnchorId("component:protocol.graph"), + role: "contract", }); void graphComponentAnchor; void graphClaimsAnchor; @@ -53,13 +58,33 @@ export type AuthoredEdgeType = (typeof authoredEdgeTypes)[number]; /** `models` is the oracle anchor's binding edge (Anchor → Primitive, anchored) — anchor-emitted * like `satisfies`, never authored; it confers no delivery fact (no `has-oracle` at MVP). * `memberOf` and `uses` are anchored CodeNode → CodeNode structure. They deliberately stay out - * of delivery facts and the reader's binding traversal, whose destination is always a Spec. */ -export const derivedEdgeTypes = ["belongsTo", "satisfies", "models", "memberOf", "uses"] as const; + * of delivery facts and the reader's binding traversal, whose destination is always a Spec. + * `references` is the anchored CodeNode → Primitive edge of the binding grain + * (`spec:decisions.anchor-binding-grain`): this code answers to that design without claiming to + * realize it. It confers no delivery fact, moves no floor, and the drift alarm ignores it. */ +export const derivedEdgeTypes = [ + "belongsTo", + "satisfies", + "models", + "memberOf", + "uses", + "references", +] as const; export type DerivedEdgeType = (typeof derivedEdgeTypes)[number]; export const graphEdgeTypes = [...authoredEdgeTypes, ...derivedEdgeTypes] as const; export type GraphEdgeType = (typeof graphEdgeTypes)[number]; +const graphEdgeTypesAnchor = codeAnchor({ + id: codeAnchorId("impl:protocol.graph-edge-types"), + label: "declares the closed list of graph edge types", + satisfies: ref("spec:extraction.derive-graph"), + component: componentAnchorId("component:protocol.graph"), + references: [ref("spec:decisions.anchor-binding-grain")], + role: "contract", +}); +void graphEdgeTypesAnchor; + interface GraphNodeBase { readonly id: string; readonly nodeType: GraphNodeType; @@ -115,12 +140,21 @@ export interface AnchorNode extends GraphNodeBase { readonly line: number; } -/** A code anchor's node (the `satisfies` edge contract row: CodeNode → Primitive, anchored). */ +/** + * A code anchor's node (the `satisfies` edge contract row: CodeNode → Primitive, anchored). The + * architectural annotation decision (`spec:decisions.architectural-annotation`) adds three + * structural attributes: `role`, the pattern the unit plays, a free corpus-owned vocabulary; + * `layer` and `context`, the declared component's layer and bounded context, on `component:` + * nodes only. None of them confers anything; the census renders their taxonomy from the graph. + */ export interface CodeNode extends GraphNodeBase { readonly nodeType: "CodeNode"; readonly label?: string; readonly file: string; readonly line?: number; + readonly role?: string; + readonly layer?: CodeAnchorLayer; + readonly context?: string; } export type GraphNode = PrimitiveNode | PackNode | AnchorNode | CodeNode; diff --git a/src/ids.ts b/src/ids.ts index 625e9fd6..88a9f4db 100644 --- a/src/ids.ts +++ b/src/ids.ts @@ -173,6 +173,7 @@ const stableIdsAnchor = codeAnchor({ label: "stable ID grammar parser", satisfies: "spec:model.stable-ids" as SpecId, component: createComponentAnchorId("component:protocol.model"), + role: "codec", }); void stableIdsAnchor; diff --git a/src/import/emit-markdown.ts b/src/import/emit-markdown.ts index 4a8b34d9..bc627653 100644 --- a/src/import/emit-markdown.ts +++ b/src/import/emit-markdown.ts @@ -205,6 +205,7 @@ const sdpImportMarkdownEmitAnchor = codeAnchor({ label: "emits the Markdown document twin for an imported Spec", satisfies: ref("spec:carrier.sdp-import"), component: componentAnchorId("component:protocol.import"), + role: "codec", }); void sdpImportMarkdownEmitAnchor; diff --git a/src/import/import.ts b/src/import/import.ts index 8a68052c..4b3bfc44 100644 --- a/src/import/import.ts +++ b/src/import/import.ts @@ -45,12 +45,15 @@ const importComponentAnchor = codeAnchor({ componentAnchorId("component:protocol.extract"), componentAnchorId("component:protocol.validate"), ], + layer: "application", + context: "protocol", }); const sdpImportCoreAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.sdp-import-core"), label: "converts a TypeScript-carrier Spec into an idiomatic Markdown twin", satisfies: ref("spec:carrier.sdp-import"), component: componentAnchorId("component:protocol.import"), + role: "service", }); void importComponentAnchor; void sdpImportCoreAnchor; diff --git a/src/model/anchors.ts b/src/model/anchors.ts index 0d910b24..33c837a6 100644 --- a/src/model/anchors.ts +++ b/src/model/anchors.ts @@ -9,32 +9,55 @@ import type { TestAnchorId, } from "../ids.js"; +/** + * The closed set of architectural layers a `component:` anchor may declare (the architectural + * annotation decision, `spec:decisions.architectural-annotation`; v0 03 §2.5). A value outside it + * is an envelope error at extraction. + */ +export const CODE_ANCHOR_LAYERS = [ + "edge", + "application", + "domain", + "adapter", + "infrastructure", +] as const; +export type CodeAnchorLayer = (typeof CODE_ANCHOR_LAYERS)[number]; + /** * 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, the one `satisfies` - * target, and closed structural references — nothing spec-level ever rides here. + * never system-truth content (R1): identity, an optional display label, the `satisfies` targets, + * the non-conferring `references` targets, and closed structural attributes — nothing spec-level + * ever rides here. The binding grain (`spec:decisions.anchor-binding-grain`): `satisfies` is + * optional and plural, so an identity-only anchor is lawful and mints a CodeNode for structure. + * `references` says this code answers to that design without claiming to realize it, and confers + * nothing. `role` is a free, corpus-owned vocabulary; `layer` and `context` belong to `component:` + * anchors only. */ export interface CodeAnchor { readonly id: CodeAnchorId; readonly label?: string; - readonly satisfies: SpecId; + readonly satisfies?: SpecId | readonly SpecId[]; + readonly references?: readonly SpecId[]; readonly component?: ComponentAnchorId; readonly uses?: readonly CodeAnchorId[]; + readonly role?: string; + readonly layer?: CodeAnchorLayer; + readonly context?: string; } /** - * 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). + * The binding-only test anchor (R3): identity plus the `verifies` targets (one or a non-empty + * list), 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; + readonly verifies: SpecId | readonly SpecId[]; } /** @@ -71,6 +94,8 @@ export function specOracle(anchor: SpecOracleAnchor): SpecOracleAnchor { const modelComponentAnchor = createCodeAnchor({ id: codeAnchorId("component:protocol.model"), label: "Protocol model seam", + layer: "domain", + context: "protocol", satisfies: ref("spec:model.core-model"), }); const anchorModelAnchor = createCodeAnchor({ @@ -78,6 +103,8 @@ const anchorModelAnchor = createCodeAnchor({ label: "binding-only anchor model builders", satisfies: ref("spec:model.anchors"), component: componentAnchorId("component:protocol.model"), + references: [ref("spec:decisions.architectural-annotation")], + role: "contract", }); void modelComponentAnchor; diff --git a/src/model/descriptors.ts b/src/model/descriptors.ts index 08c24937..206efb6a 100644 --- a/src/model/descriptors.ts +++ b/src/model/descriptors.ts @@ -40,6 +40,7 @@ const specDescriptorsAnchor = codeAnchor({ label: "Spec kind, altitude, and readiness coordinates", satisfies: ref("spec:model.core-model"), component: componentAnchorId("component:protocol.model"), + role: "contract", }); void specDescriptorsAnchor; diff --git a/src/model/pack.ts b/src/model/pack.ts index 8f4c1714..ce553263 100644 --- a/src/model/pack.ts +++ b/src/model/pack.ts @@ -24,6 +24,7 @@ const packAggregateAnchor = codeAnchor({ label: "Pack aggregate and model references", satisfies: ref("spec:model.pack-aggregate"), component: componentAnchorId("component:protocol.model"), + role: "contract", }); void packAggregateAnchor; diff --git a/src/model/relations.ts b/src/model/relations.ts index 3b234085..7f80d202 100644 --- a/src/model/relations.ts +++ b/src/model/relations.ts @@ -65,6 +65,7 @@ const specRelationsAnchor = codeAnchor({ label: "declared Spec relation builders", satisfies: ref("spec:model.relations"), component: componentAnchorId("component:protocol.model"), + role: "contract", }); void specRelationsAnchor; diff --git a/src/model/sections.ts b/src/model/sections.ts index 4a0bd6c5..b0cd5ea1 100644 --- a/src/model/sections.ts +++ b/src/model/sections.ts @@ -128,6 +128,7 @@ const specSectionsAnchor = codeAnchor({ label: "typed Spec section shapes", satisfies: ref("spec:model.spec-sections"), component: componentAnchorId("component:protocol.model"), + role: "contract", }); void specSectionsAnchor; diff --git a/src/model/spec.ts b/src/model/spec.ts index 20e3b201..87d56fec 100644 --- a/src/model/spec.ts +++ b/src/model/spec.ts @@ -22,6 +22,7 @@ const specPrimitiveAnchor = codeAnchor({ label: "Spec envelope and enrich-in-place shape", satisfies: ref("spec:model.core-model"), component: componentAnchorId("component:protocol.model"), + role: "contract", }); void specPrimitiveAnchor; diff --git a/src/notation/slots.ts b/src/notation/slots.ts index ce021439..76d79b63 100644 --- a/src/notation/slots.ts +++ b/src/notation/slots.ts @@ -257,12 +257,15 @@ const notationComponentAnchor = codeAnchor({ id: codeAnchorId("component:protocol.notation"), label: "Protocol slot notation seam", satisfies: ref("spec:carrier.slot-notation"), + layer: "domain", + context: "protocol", }); const slotNotationAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.slot-notation"), label: "parses slot groups and normalizes a step to its skeleton", satisfies: ref("spec:carrier.slot-notation"), component: componentAnchorId("component:protocol.notation"), + role: "codec", }); void notationComponentAnchor; diff --git a/src/projections/census.ts b/src/projections/census.ts index 7119ddf8..34332cde 100644 --- a/src/projections/census.ts +++ b/src/projections/census.ts @@ -1,7 +1,6 @@ import { deliveryFactNames, graphClaims, graphEdgeTypes, graphNodeTypes } from "../graph/schema.js"; import type { GraphEdge, GraphSchema } from "../graph/schema.js"; -import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; -import { codeAnchor } from "../model/code-anchor.js"; +import { CODE_ANCHOR_LAYERS } from "../model/anchors.js"; import { SPEC_ALTITUDES, SPEC_KIND_DISPLAY_LABELS, @@ -18,13 +17,14 @@ export interface CensusPage { readonly content: string; } -const censusAnchor = codeAnchor({ - id: codeAnchorId("impl:protocol.census-page"), - label: "renders the derived census and runtime taxonomy projection", - satisfies: ref("spec:consumers.census-page"), - component: componentAnchorId("component:protocol.projections"), -}); -void censusAnchor; +/** + * @sdpAnchor impl:protocol.census-page + * @sdpLabel renders the derived census and runtime taxonomy projection + * @sdpSatisfies spec:consumers.census-page + * @sdpComponent component:protocol.projections + * @sdpReferences spec:decisions.architectural-annotation + * @sdpRole projection + */ function compareCodeUnits(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; @@ -138,20 +138,167 @@ function namespaceOf(id: string): string { return separator <= 0 ? "unrecognized: no namespace" : id.slice(0, separator); } +const structuralEdgeTypes = ["memberOf", "uses", "references"] as const; + function structuralEdges(graph: GraphSchema): readonly GraphEdge[] { - return graph.edges.filter((edge) => edge.type === "memberOf" || edge.type === "uses"); + const types = new Set(structuralEdgeTypes); + return graph.edges.filter((edge) => types.has(edge.type)); +} + +/** The structural attributes a code unit carries, read as recorded (`role`, `layer`, `context`). */ +interface CodeUnitAttributes { + readonly id: string; + readonly role?: string; + readonly layer?: string; + readonly context?: string; +} + +function codeUnits(graph: GraphSchema): readonly CodeUnitAttributes[] { + const text = (value: unknown): string | undefined => + typeof value === "string" ? value : undefined; + + return graph.nodes + .filter((node) => node.nodeType === "CodeNode") + .map((node) => { + const record = node as unknown as Readonly>; + const role = text(record.role); + const layer = text(record.layer); + const context = text(record.context); + return { + id: node.id, + ...(role === undefined ? {} : { role }), + ...(layer === undefined ? {} : { layer }), + ...(context === undefined ? {} : { context }), + }; + }) + .sort( + (left, right) => + compareCodeUnits(left.id, right.id) || + compareCodeUnits(left.role ?? "", right.role ?? "") || + compareCodeUnits(left.layer ?? "", right.layer ?? "") || + compareCodeUnits(left.context ?? "", right.context ?? ""), + ); +} + +function renderIdList(ids: readonly string[]): string { + return ids.length === 0 ? "—" : ids.map((id) => `\`${escapeRenderedField(id)}\``).join(", "); +} + +/** + * One structural attribute as a value → units table, so an owner can reconcile synonyms. A closed + * runtime set (`layer`) renders every value even at zero and names a foreign value `unrecognized`; + * a corpus-owned vocabulary (`role`, `context`) renders the values the graph holds. + */ +function renderAttributeTaxonomy( + heading: string, + column: string, + unitsByValue: ReadonlyMap, + runtimeValues?: readonly string[], +): readonly string[] { + const row = (label: string, units: readonly string[]): string => + `| ${label} | ${renderIdList(units)} | ${String(units.length)} |`; + const runtimeSet = new Set(runtimeValues ?? []); + const known = (runtimeValues ?? []).map((value) => + row(`\`${escapeRenderedField(value)}\``, unitsByValue.get(value) ?? []), + ); + const observed = [...unitsByValue.keys()] + .filter((value) => !runtimeSet.has(value)) + .sort(compareCodeUnits) + .map((value) => + row( + runtimeValues === undefined + ? `\`${escapeRenderedField(value)}\`` + : `unrecognized: \`${escapeRenderedField(value)}\``, + unitsByValue.get(value) ?? [], + ), + ); + const rows = [...known, ...observed]; + + return [ + `### ${heading}`, + "", + `| ${column} | Units | Unit count |`, + "| --- | --- | ---: |", + ...(rows.length === 0 ? ["| — | None | 0 |"] : rows), + "", + ]; +} + +/** Unit ids by one attribute's value, in unit order; each bucket is created once and pushed to. */ +function unitsBy( + units: readonly CodeUnitAttributes[], + attribute: "role" | "layer" | "context", +): ReadonlyMap { + const grouped = new Map(); + + for (const unit of units) { + const value = unit[attribute]; + + if (value === undefined) { + continue; + } + + const bucket = grouped.get(value); + + if (bucket === undefined) { + grouped.set(value, [unit.id]); + } else { + bucket.push(unit.id); + } + } + + return grouped; +} + +function renderReferences(referenceEdges: readonly GraphEdge[]): readonly string[] { + const specsByUnit = new Map>(); + + for (const edge of referenceEdges) { + const specs = specsByUnit.get(edge.from) ?? new Set(); + specs.add(edge.to); + specsByUnit.set(edge.from, specs); + } + + const rows = [...specsByUnit.entries()] + .sort(([left], [right]) => compareCodeUnits(left, right)) + .map(([unit, specs]) => { + const sorted = [...specs].sort(compareCodeUnits); + return `| \`${escapeRenderedField(unit)}\` | ${renderIdList(sorted)} | ${String(sorted.length)} |`; + }); + + return [ + "### References", + "", + "A `references` edge says the unit answers to that design without claiming to realize it; it confers no delivery fact.", + "", + "| Unit | Specs | Spec count |", + "| --- | --- | ---: |", + ...(rows.length === 0 ? ["| — | None | 0 |"] : rows), + "", + ]; } function renderStructuralBindings(reader: Reader): readonly string[] { const edges = structuralEdges(reader.graph); + const units = codeUnits(reader.graph); + const edgeCount = (type: string): number => edges.filter((edge) => edge.type === type).length; const lines = [ "## Structural bindings", "", - "Authored `memberOf` and `uses` CodeNode edges are rendered as structure; they confer no delivery fact or readiness.", + "Anchored `memberOf`, `uses`, and `references` edges and the `role`, `layer`, and `context` attributes of code units are rendered as structure; they confer no delivery fact or readiness. Units are counted apart from edges.", + "", + "| Structure | Count |", + "| --- | ---: |", + `| Code units | ${String(units.length)} |`, + `| Component units | ${String(units.filter((unit) => namespaceOf(unit.id) === "component").length)} |`, + ...structuralEdgeTypes.map((type) => `| \`${type}\` edges | ${String(edgeCount(type))} |`), "", ]; + const attributed = units.some( + (unit) => unit.role !== undefined || unit.layer !== undefined || unit.context !== undefined, + ); - if (edges.length === 0) { + if (edges.length === 0 && !attributed) { return [...lines, "No structural bindings exist.", ""]; } @@ -207,6 +354,9 @@ function renderStructuralBindings(reader: Reader): readonly string[] { `| \`${escapeRenderedField(component)}\` | ${members.length === 0 ? "—" : members.map((member) => `\`${escapeRenderedField(member)}\``).join(", ")} | ${String(members.length)} |`, ); } + if (components.size === 0) { + lines.push("| — | None | 0 |"); + } lines.push( "", "### Uses fan-in / fan-out per component", @@ -221,7 +371,17 @@ function renderStructuralBindings(reader: Reader): readonly string[] { `| \`${escapeRenderedField(component)}\` | ${String(fanIn.get(component) ?? 0)} | ${String(fanOut.get(component) ?? 0)} |`, ); } - lines.push("", ...renderUsesCycles(usesEdges)); + if (components.size === 0) { + lines.push("| — | 0 | 0 |"); + } + lines.push( + "", + ...renderUsesCycles(usesEdges), + ...renderAttributeTaxonomy("Layers", "Layer", unitsBy(units, "layer"), CODE_ANCHOR_LAYERS), + ...renderAttributeTaxonomy("Contexts", "Context", unitsBy(units, "context")), + ...renderAttributeTaxonomy("Roles", "Role", unitsBy(units, "role")), + ...renderReferences(edges.filter((edge) => edge.type === "references")), + ); const structuralIds = new Set(edges.flatMap((edge) => [edge.from, edge.to])); const dangling = reader diff --git a/src/projections/design-review-context.ts b/src/projections/design-review-context.ts index d6c96fc1..b6505682 100644 --- a/src/projections/design-review-context.ts +++ b/src/projections/design-review-context.ts @@ -1,7 +1,10 @@ import { SPEC_READINESS } from "../model/descriptors.js"; -import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; -import { codeAnchor } from "../model/code-anchor.js"; -import type { RelationEnd, SpecContext, VerifierBinding } from "../reader/reader.js"; +import type { + CodeUnitBinding, + RelationEnd, + SpecContext, + VerifierBinding, +} from "../reader/reader.js"; import type { Finding } from "../validate/contracts.js"; import { escapeRenderedField } from "./owned-prose.js"; import { @@ -12,13 +15,13 @@ import { tableCell, } from "./design-review-markdown.js"; -const derivedReadinessBannerAnchor = codeAnchor({ - id: codeAnchorId("impl:protocol.derived-readiness-banner"), - label: "renders stated readiness beside the structural floor and dishonest divergence", - satisfies: ref("spec:consumers.derived-readiness-banner"), - component: componentAnchorId("component:protocol.projections"), -}); -void derivedReadinessBannerAnchor; +/** + * @sdpAnchor impl:protocol.derived-readiness-banner + * @sdpLabel renders stated readiness beside the structural floor and dishonest divergence + * @sdpSatisfies spec:consumers.derived-readiness-banner + * @sdpComponent component:protocol.projections + * @sdpRole renderer + */ export function renderReadiness(context: SpecContext): readonly string[] { const derived = context.derivedReadiness; @@ -64,14 +67,38 @@ function describeVerifier(verifier: VerifierBinding): string { : "**not enabled** (an off-contract `verifies` edge — it confers no verifier binding)"; } -const bindingLanguageSpecPageAnchor = codeAnchor({ - id: codeAnchorId("impl:protocol.binding-language-spec-page"), - label: "renders implementation, verifier, oracle, and observation binding language", - satisfies: ref("spec:consumers.binding-language-views"), - component: componentAnchorId("component:protocol.projections"), -}); -void bindingLanguageSpecPageAnchor; +/** + * One code unit row: identity, label, source location, the structural attributes the unit + * carries (`role` on any unit; `layer` and `context` on a component), and the edge's claim. + */ +function renderCodeUnitRow(binding: CodeUnitBinding, page: string): string { + const label = binding.label === undefined ? "" : ` — ${escapeRenderedField(binding.label)}`; + const location = + binding.file === undefined + ? "" + : ` ([${escapeRenderedField(binding.file)}${binding.line === undefined ? "" : `:${String(binding.line)}`}](${sourceHref(page, binding.file)}))`; + const attributes = ( + [ + ["role", binding.role], + ["layer", binding.layer], + ["context", binding.context], + ] as const + ) + .flatMap(([name, value]) => + value === undefined ? [] : [`${name} \`${escapeRenderedField(value)}\``], + ) + .join(" · "); + + return `- \`${binding.codeId}\`${label}${location}${attributes === "" ? "" : ` · ${attributes}`} \`[${binding.claim}]\``; +} +/** + * @sdpAnchor impl:protocol.binding-language-spec-page + * @sdpLabel renders implementation, verifier, oracle, and observation binding language + * @sdpSatisfies spec:consumers.binding-language-views + * @sdpComponent component:protocol.projections + * @sdpRole renderer + */ export function renderBindings(context: SpecContext, page: string): readonly string[] { const present = (fact: "implemented" | "has-verifier"): string => context.deliveryFacts.includes(fact) ? "present" : "none"; @@ -89,12 +116,21 @@ export function renderBindings(context: SpecContext, page: string): readonly str lines.push("", "### Implementations", ""); for (const binding of context.implementations) { - const label = binding.label === undefined ? "" : ` — ${escapeRenderedField(binding.label)}`; - const location = - binding.file === undefined - ? "" - : ` ([${escapeRenderedField(binding.file)}${binding.line === undefined ? "" : `:${String(binding.line)}`}](${sourceHref(page, binding.file)}))`; - lines.push(`- \`${binding.codeId}\`${label}${location} \`[${binding.claim}]\``); + lines.push(renderCodeUnitRow(binding, page)); + } + } + + if (context.references.length > 0) { + lines.push( + "", + "### Referenced by", + "", + "Code that answers to this design without claiming to realize it. A reference confers no implementation binding.", + "", + ); + + for (const binding of context.references) { + lines.push(renderCodeUnitRow(binding, page)); } } @@ -169,13 +205,13 @@ export function renderRelationsAndImpact(context: SpecContext, page: string): re return lines.length === 4 ? [] : lines; } -const diagnosticDesignReviewAnchor = codeAnchor({ - id: codeAnchorId("impl:protocol.diagnostic-rendering-design-review"), - label: "renders structured finding locations in the Design Review table", - satisfies: ref("spec:validation.diagnostic-rendering"), - component: componentAnchorId("component:protocol.projections"), -}); -void diagnosticDesignReviewAnchor; +/** + * @sdpAnchor impl:protocol.diagnostic-rendering-design-review + * @sdpLabel renders structured finding locations in the Design Review table + * @sdpSatisfies spec:validation.diagnostic-rendering + * @sdpComponent component:protocol.projections + * @sdpRole renderer + */ export function renderFindings(findings: readonly Finding[]): readonly string[] { if (findings.length === 0) { diff --git a/src/projections/design-review-pages.ts b/src/projections/design-review-pages.ts index e040f7a9..619f0ba4 100644 --- a/src/projections/design-review-pages.ts +++ b/src/projections/design-review-pages.ts @@ -1,6 +1,4 @@ import type { PackContext, Reader, SpecContext, SpecSummary } from "../reader/reader.js"; -import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; -import { codeAnchor } from "../model/code-anchor.js"; import { escapeRenderedField, renderNarrative } from "./owned-prose.js"; import type { DesignReviewPage } from "./design-review.js"; import { @@ -48,13 +46,13 @@ export function renderSpecPage(context: SpecContext): DesignReviewPage { return { path: page, content: `${lines.join("\n")}\n` }; } -const bindingLanguagePackTableAnchor = codeAnchor({ - id: codeAnchorId("impl:protocol.binding-language-pack-table"), - label: "renders Pack member implementation and verifier bindings as present or none", - satisfies: ref("spec:consumers.binding-language-views"), - component: componentAnchorId("component:protocol.projections"), -}); -void bindingLanguagePackTableAnchor; +/** + * @sdpAnchor impl:protocol.binding-language-pack-table + * @sdpLabel renders Pack member implementation and verifier bindings as present or none + * @sdpSatisfies spec:consumers.binding-language-views + * @sdpComponent component:protocol.projections + * @sdpRole renderer + */ export function renderPackPage( context: PackContext, @@ -121,13 +119,13 @@ export function renderPackPage( return { path: page, content: `${lines.join("\n")}\n` }; } -const bindingLanguageIndexTableAnchor = codeAnchor({ - id: codeAnchorId("impl:protocol.binding-language-index-table"), - label: "renders index implementation and verifier bindings as present or none", - satisfies: ref("spec:consumers.binding-language-views"), - component: componentAnchorId("component:protocol.projections"), -}); -void bindingLanguageIndexTableAnchor; +/** + * @sdpAnchor impl:protocol.binding-language-index-table + * @sdpLabel renders index implementation and verifier bindings as present or none + * @sdpSatisfies spec:consumers.binding-language-views + * @sdpComponent component:protocol.projections + * @sdpRole renderer + */ export function renderIndexPage(reader: Reader, specs: readonly SpecSummary[]): DesignReviewPage { const page = "index.md"; diff --git a/src/projections/design-review.ts b/src/projections/design-review.ts index c3823c69..7ec8de74 100644 --- a/src/projections/design-review.ts +++ b/src/projections/design-review.ts @@ -1,5 +1,3 @@ -import { codeAnchor } from "../model/code-anchor.js"; -import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; import type { Reader } from "../reader/reader.js"; import { escapeRenderedField } from "./owned-prose.js"; import { pageHref, pagePathOf } from "./design-review-markdown.js"; @@ -18,43 +16,35 @@ import { renderIndexPage, renderPackPage, renderSpecPage } from "./design-review * never liveness. Stated readiness renders beside the structurally-reached floor, and the * divergence banner fires only in the dishonest direction (`spec:consumers.derived-readiness-banner`). */ +/** + * @sdpAnchor impl:protocol.projections-model + * @sdpLabel pure generated projection page contract + * @sdpSatisfies spec:consumers.projections-model + * @sdpComponent component:protocol.projections + * @sdpRole contract + */ 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; } -const projectionsComponentAnchor = codeAnchor({ - id: codeAnchorId("component:protocol.projections"), - label: "Protocol projections seam", - satisfies: ref("spec:consumers.projections-model"), - uses: [ - componentAnchorId("component:protocol.reader"), - componentAnchorId("component:protocol.graph"), - componentAnchorId("component:protocol.extract"), - componentAnchorId("component:protocol.model"), - componentAnchorId("component:protocol.notation"), - componentAnchorId("component:protocol.validate"), - ], -}); -const projectionModelAnchor = codeAnchor({ - id: codeAnchorId("impl:protocol.projections-model"), - label: "pure generated projection page contract", - satisfies: ref("spec:consumers.projections-model"), - component: componentAnchorId("component:protocol.projections"), -}); - -void projectionsComponentAnchor; -void projectionModelAnchor; - -const designReviewAnchor = codeAnchor({ - id: codeAnchorId("impl:protocol.design-review"), - label: "renders the contextual Design Review projection", - satisfies: ref("spec:consumers.design-review"), - component: componentAnchorId("component:protocol.projections"), -}); +/** + * @sdpAnchor component:protocol.projections + * @sdpLabel Protocol projections seam + * @sdpSatisfies spec:consumers.projections-model + * @sdpUses component:protocol.reader, component:protocol.graph, component:protocol.extract, component:protocol.model, component:protocol.notation, component:protocol.validate + * @sdpLayer edge + * @sdpContext protocol + */ -void designReviewAnchor; +/** + * @sdpAnchor impl:protocol.design-review + * @sdpLabel renders the contextual Design Review projection + * @sdpSatisfies spec:consumers.design-review + * @sdpComponent component:protocol.projections + * @sdpRole projection + */ /** * Renders the full Design Review off the reader. Pure and fs-free: the caller owns writing the diff --git a/src/projections/gherkin-view.ts b/src/projections/gherkin-view.ts index cd150871..a824760c 100644 --- a/src/projections/gherkin-view.ts +++ b/src/projections/gherkin-view.ts @@ -1,6 +1,4 @@ import { GHERKIN_KIND_LIE_REASONS } from "../extract/gherkin-kind-honesty.js"; -import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; -import { codeAnchor } from "../model/code-anchor.js"; import type { SpecKind } from "../model/descriptors.js"; import type { BehaviorSection, @@ -18,13 +16,13 @@ export interface GherkinViewPage { readonly content: string; } -const gherkinViewAnchor = codeAnchor({ - id: codeAnchorId("impl:protocol.gherkin-view"), - label: "renders a generated Gherkin-shaped READ projection of any Spec", - satisfies: ref("spec:consumers.gherkin-view"), - component: componentAnchorId("component:protocol.projections"), -}); -void gherkinViewAnchor; +/** + * @sdpAnchor impl:protocol.gherkin-view + * @sdpLabel renders a generated Gherkin-shaped READ projection of any Spec + * @sdpSatisfies spec:consumers.gherkin-view + * @sdpComponent component:protocol.projections + * @sdpRole projection + */ const CANONICAL_KINDS = new Set(["behavior", "example"]); diff --git a/src/projections/mermaid.ts b/src/projections/mermaid.ts index 537064c8..40b68807 100644 --- a/src/projections/mermaid.ts +++ b/src/projections/mermaid.ts @@ -1,6 +1,4 @@ -import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; import type { GraphEdge, GraphNode, GraphSchema } from "../graph/schema.js"; -import { codeAnchor } from "../model/code-anchor.js"; import type { Reader } from "../reader/reader.js"; import { renderDiagnosticBanner } from "./diagnostic-banner.js"; @@ -13,13 +11,13 @@ export interface MermaidPage { readonly content: string; } -const mermaidViewAnchor = codeAnchor({ - id: codeAnchorId("impl:protocol.mermaid-view"), - label: "renders bounded Spec one-hop and Pack membership diagrams", - satisfies: ref("spec:consumers.mermaid-view"), - component: componentAnchorId("component:protocol.projections"), -}); -void mermaidViewAnchor; +/** + * @sdpAnchor impl:protocol.mermaid-view + * @sdpLabel renders bounded Spec one-hop and Pack membership diagrams + * @sdpSatisfies spec:consumers.mermaid-view + * @sdpComponent component:protocol.projections + * @sdpRole projection + */ function compareCodeUnits(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; diff --git a/src/reader/reader.ts b/src/reader/reader.ts index 8c67008f..26be3271 100644 --- a/src/reader/reader.ts +++ b/src/reader/reader.ts @@ -5,8 +5,6 @@ import { } from "../graph/delivery-facts.js"; import { isResolvingOracleModel } from "../graph/oracle-bindings.js"; import { authoredEdgeTypes } from "../graph/schema.js"; -import { codeAnchorId, componentAnchorId, ref } from "../ids.js"; -import { codeAnchor } from "../model/code-anchor.js"; import type { DeliveryFactName, GraphClaim, @@ -17,6 +15,7 @@ import type { PackNode, PrimitiveNode, } from "../graph/schema.js"; +import type { CodeAnchorLayer } from "../model/anchors.js"; import { SPEC_KIND_DISPLAY_LABELS, SPEC_READINESS } from "../model/descriptors.js"; import type { SpecAltitude, SpecKind, SpecReadiness } from "../model/descriptors.js"; import type { SpecSections } from "../model/sections.js"; @@ -69,15 +68,33 @@ export interface RelationEnd { readonly otherTitle?: string; } -/** A code binding (`satisfies` from a `CodeNode`) decoded to its source location. */ -export interface ImplementationBinding { +/** + * A code unit at the source end of a binding edge, decoded to its source location and its + * structural attributes (`spec:decisions.architectural-annotation`): `role` on any code unit, + * `layer` and `context` on a `component:` unit. The attributes are carried as recorded; none of + * them confers anything. + */ +export interface CodeUnitBinding { readonly codeId: string; readonly claim: GraphClaim; readonly label?: string; readonly file?: string; readonly line?: number; + readonly role?: string; + readonly layer?: CodeAnchorLayer; + readonly context?: string; } +/** A code binding (`satisfies` from a `CodeNode`) decoded to its source location. */ +export type ImplementationBinding = CodeUnitBinding; + +/** + * A design reference (`references` from a `CodeNode`): this code answers to the Spec's design + * without claiming to realize it (`spec:decisions.anchor-binding-grain`). It confers no delivery + * fact and moves no floor, so it rides beside the implementations, never inside them. + */ +export type ReferenceBinding = CodeUnitBinding; + /** * 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 @@ -120,6 +137,8 @@ export interface SpecContext extends SpecSummary { /** Authored-type edges pointing at the spec — who refines / depends on / verifies it. */ readonly relationsIn: readonly RelationEnd[]; readonly implementations: readonly ImplementationBinding[]; + /** The code units that reference the Spec: they answer to its design, conferring nothing. */ + readonly references: readonly ReferenceBinding[]; readonly verifiers: readonly VerifierBinding[]; readonly oracle?: OracleBinding; /** The graph findings naming this spec (as subject or related) — the holes beside the assertions. */ @@ -174,7 +193,11 @@ export interface FileNodeRef { readonly line?: number; } -/** The file→graph bridge: what the graph records at a path, and the specs reachable from it. */ +/** + * The file→graph bridge: what the graph records at a path, and the specs reachable from it. A + * code unit with no `satisfies` or `references` edge is still listed in `nodes`; the Spec list + * may then be empty (an identity-only anchor is lawful). + */ export interface FileEntry { readonly path: string; readonly nodes: readonly FileNodeRef[]; @@ -213,12 +236,27 @@ export interface AtRiskItem { readonly reasons: readonly AtRiskReason[]; } +/** A changed code unit the graph records with no Spec linkage: no `satisfies`, no `references`. */ +export interface UnlinkedCodeUnit { + readonly file: string; + readonly id: string; +} + /** * 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. + * graph records nothing at, surfaced as `coverageUnknown`, never silently dropped. A changed code + * unit with no Spec linkage is recorded, so its file is not `coverageUnknown`; it is listed in + * `unlinked` instead, never dropped and never read as coverage. The answer never claims + * exhaustive reach: deeper walks are scripts over the same shapes, and symbol-level reach is the + * aspirational impact graph. + */ +/** + * @sdpAnchor impl:protocol.reader-impact + * @sdpLabel file-level reader blast-radius contract + * @sdpSatisfies spec:consumers.reader + * @sdpComponent component:protocol.reader + * @sdpRole contract */ export interface BlastRadius { readonly changedFiles: readonly string[]; @@ -226,17 +264,9 @@ export interface BlastRadius { readonly impactedPacks: readonly ImpactedItem[]; readonly atRisk: readonly AtRiskItem[]; readonly coverageUnknown: readonly string[]; + readonly unlinked: readonly UnlinkedCodeUnit[]; } -const readerImpactAnchor = codeAnchor({ - id: codeAnchorId("impl:protocol.reader-impact"), - label: "file-level reader blast-radius contract", - satisfies: ref("spec:consumers.reader"), - component: componentAnchorId("component:protocol.reader"), -}); - -void readerImpactAnchor; - /* ----- the reader ----- */ /** @@ -355,35 +385,30 @@ function matchSections(sections: SpecSections | undefined, needle: string): read return matched.sort(compareCodeUnits); } -const readerComponentAnchor = codeAnchor({ - id: codeAnchorId("component:protocol.reader"), - label: "Protocol reader seam", - satisfies: ref("spec:consumers.reader"), - uses: [ - componentAnchorId("component:protocol.graph"), - componentAnchorId("component:protocol.validate"), - componentAnchorId("component:protocol.model"), - ], -}); -const agentSurfaceAnchor = codeAnchor({ - id: codeAnchorId("impl:protocol.agent-surface"), - label: "typed graph reader and agent entry adapters", - satisfies: ref("spec:consumers.agent-surface"), - component: componentAnchorId("component:protocol.reader"), -}); - -void agentSurfaceAnchor; - -const readerAnchor = codeAnchor({ - id: codeAnchorId("impl:protocol.reader"), - label: "thin typed graph reader construction", - satisfies: ref("spec:consumers.reader"), - component: componentAnchorId("component:protocol.reader"), - uses: [codeAnchorId("impl:protocol.delivery-facts")], -}); - -void readerComponentAnchor; -void readerAnchor; +/** + * @sdpAnchor component:protocol.reader + * @sdpLabel Protocol reader seam + * @sdpSatisfies spec:consumers.reader + * @sdpUses component:protocol.graph, component:protocol.validate, component:protocol.model + * @sdpLayer application + * @sdpContext protocol + */ +/** + * @sdpAnchor impl:protocol.agent-surface + * @sdpLabel typed graph reader and agent entry adapters + * @sdpSatisfies spec:consumers.agent-surface + * @sdpComponent component:protocol.reader + * @sdpRole reader + */ + +/** + * @sdpAnchor impl:protocol.reader + * @sdpLabel thin typed graph reader construction + * @sdpSatisfies spec:consumers.reader + * @sdpComponent component:protocol.reader + * @sdpUses impl:protocol.delivery-facts + * @sdpRole reader + */ export function createReader(graph: GraphSchema): Reader { const index = buildGraphIndex(graph); @@ -402,8 +427,11 @@ export function createReader(graph: GraphSchema): Reader { ); // Structural memberOf/uses edges are intentionally excluded: this traversal follows binding // nodes to Specs, while structural edges connect CodeNode endpoints and confer no Spec linkage. + // `references` is traversed: it confers nothing, but it is the code unit's recorded linkage to + // the design it answers to, so a change to that unit reaches the Spec. const isTraversableBinding = (edge: GraphEdge): boolean => edge.type === "satisfies" || + edge.type === "references" || edge.type === "verifies" || (edge.type === "models" && usableOracleEdges.has(edge)); @@ -470,22 +498,29 @@ export function createReader(graph: GraphSchema): Reader { .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 codeUnitBindings = (edgeType: "satisfies" | "references"): CodeUnitBinding[] => + (index.edgesByTo.get(id) ?? []) + .filter((edge) => edge.type === edgeType) + .map((edge): CodeUnitBinding => { + 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 }), + ...(source.role === undefined ? {} : { role: source.role }), + ...(source.layer === undefined ? {} : { layer: source.layer }), + ...(source.context === undefined ? {} : { context: source.context }), + } + : {}; + + return { codeId: edge.from, claim: edge.claim, ...location }; + }) + .sort((left, right) => compareCodeUnits(left.codeId, right.codeId)); + + const implementations: readonly ImplementationBinding[] = codeUnitBindings("satisfies"); + const references: readonly ReferenceBinding[] = codeUnitBindings("references"); const anchorVerified = (verifierId: string): boolean => (index.edgesByTo.get(verifierId) ?? []).some((edge) => @@ -565,6 +600,7 @@ export function createReader(graph: GraphSchema): Reader { relationsOut, relationsIn, implementations, + references, verifiers, ...(oracle === undefined ? {} : { oracle }), findings: findingsNaming(id), @@ -737,6 +773,7 @@ export function createReader(graph: GraphSchema): Reader { const impactedSpecReasons = new Map(); const impactedPackReasons = new Map(); const coverageUnknown: string[] = []; + const unlinked: UnlinkedCodeUnit[] = []; const appendReason = ( map: Map, @@ -769,14 +806,23 @@ export function createReader(graph: GraphSchema): Reader { continue; } + let linked = false; + for (const edge of index.edgesByFrom.get(ref.id) ?? []) { if (isTraversableBinding(edge)) { + linked = true; appendReason(impactedSpecReasons, edge.to, { file, throughBinding: { id: ref.id, edgeType: edge.type, claim: edge.claim }, }); } } + + // A recorded code unit with no Spec linkage (an identity-only anchor) is named, never + // dropped: its file is not coverage-unknown, and it implies no coverage either. + if (!linked && ref.nodeType === "CodeNode") { + unlinked.push({ file, id: ref.id }); + } } } @@ -843,6 +889,7 @@ export function createReader(graph: GraphSchema): Reader { impactedPacks: toImpacted(impactedPackReasons), atRisk, coverageUnknown, + unlinked, }; }; diff --git a/src/runner/index.ts b/src/runner/index.ts index 9176a182..bae99baf 100644 --- a/src/runner/index.ts +++ b/src/runner/index.ts @@ -94,12 +94,15 @@ const runnerComponentAnchor = codeAnchor({ label: "Protocol example runner seam", satisfies: ref("spec:extraction.example-runner"), uses: [componentAnchorId("component:protocol.notation")], + layer: "application", + context: "protocol", }); const exampleRunnerAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.example-runner"), label: "plans and executes a bound example against the caller's world", satisfies: ref("spec:extraction.example-runner"), component: componentAnchorId("component:protocol.runner"), + role: "service", }); void runnerComponentAnchor; diff --git a/src/testing/index.ts b/src/testing/index.ts index 33360cbb..acf65451 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -38,12 +38,15 @@ const testingComponentAnchor = codeAnchor({ componentAnchorId("component:protocol.adapters"), componentAnchorId("component:protocol.runner"), ], + layer: "adapter", + context: "protocol", }); const exampleTestingHelpersAnchor = codeAnchor({ id: codeAnchorId("impl:protocol.example-testing-helpers"), label: "adapter-lifecycle and oracle-comparison helpers for bound examples", satisfies: ref("spec:extraction.example-runner"), component: componentAnchorId("component:protocol.testing"), + role: "utility", }); void testingComponentAnchor; void exampleTestingHelpersAnchor; diff --git a/src/validate/contracts.ts b/src/validate/contracts.ts index 56539a79..fcc7ee38 100644 --- a/src/validate/contracts.ts +++ b/src/validate/contracts.ts @@ -7,6 +7,7 @@ const validationContractsAnchor = codeAnchor({ label: "conformance/honesty family currency and finding shapes", satisfies: ref("spec:validation.two-check-families"), component: componentAnchorId("component:protocol.validate"), + role: "contract", }); void validationContractsAnchor; diff --git a/src/validate/graph-index.ts b/src/validate/graph-index.ts index f8c77aba..90228c60 100644 --- a/src/validate/graph-index.ts +++ b/src/validate/graph-index.ts @@ -30,6 +30,7 @@ const graphIndexAnchor = codeAnchor({ label: "builds the one-graph index validators and the reader share", satisfies: ref("spec:validation.two-check-families"), component: componentAnchorId("component:protocol.validate"), + role: "utility", }); void graphIndexAnchor; diff --git a/src/validate/readiness-floor.ts b/src/validate/readiness-floor.ts index f377f139..74c6a0a2 100644 --- a/src/validate/readiness-floor.ts +++ b/src/validate/readiness-floor.ts @@ -349,6 +349,7 @@ const typedDependencyFloorAnchor = codeAnchor({ label: "checks stated readiness across all four typed dependencies", satisfies: ref("spec:validation.typed-dependency-floor"), component: componentAnchorId("component:protocol.validate"), + role: "validator", }); void typedDependencyFloorAnchor; @@ -399,6 +400,7 @@ const kindEvidenceAnchor = codeAnchor({ label: "defines the code-level per-kind evidence rows for readiness floors", satisfies: ref("spec:validation.kind-evidence"), component: componentAnchorId("component:protocol.validate"), + role: "contract", }); void kindEvidenceAnchor; @@ -594,6 +596,7 @@ const verifierSemanticsAnchor = codeAnchor({ label: "readiness clauses over direct verification bindings", satisfies: ref("spec:model.spec-sections"), component: componentAnchorId("component:protocol.validate"), + role: "validator", }); void verifierSemanticsAnchor; @@ -603,6 +606,7 @@ const nextRungFloorAnchor = codeAnchor({ label: "evaluates the floor for a target rung and names the targets that break a dependency", satisfies: ref("spec:validation.next-rung-floor"), component: componentAnchorId("component:protocol.validate"), + role: "validator", }); void nextRungFloorAnchor; @@ -611,6 +615,7 @@ export const readinessFloorAnchor = codeAnchor({ label: "evaluates the stated readiness floor against the graph", satisfies: ref("spec:validation.readiness-floor"), component: componentAnchorId("component:protocol.validate"), + role: "validator", }); export function evaluateReadinessFloor( diff --git a/src/validate/validators.ts b/src/validate/validators.ts index a4ce2723..4f42cad6 100644 --- a/src/validate/validators.ts +++ b/src/validate/validators.ts @@ -60,6 +60,7 @@ const validationFamiliesAnchor = codeAnchor({ label: "conformance and honesty validator registry", satisfies: ref("spec:validation.two-check-families"), component: componentAnchorId("component:protocol.validate"), + role: "contract", }); const validateComponentAnchor = codeAnchor({ id: codeAnchorId("component:protocol.validate"), @@ -70,6 +71,8 @@ const validateComponentAnchor = codeAnchor({ componentAnchorId("component:protocol.model"), componentAnchorId("component:protocol.notation"), ], + layer: "domain", + context: "protocol", }); void validationFamiliesAnchor; @@ -174,6 +177,7 @@ const referentialIntegrityAnchor = codeAnchor({ label: "checks every graph edge endpoint and Pack model reference resolves", satisfies: ref("spec:validation.referential-integrity"), component: componentAnchorId("component:protocol.validate"), + role: "validator", }); void referentialIntegrityAnchor; @@ -418,6 +422,7 @@ function checkEdgeContractRow(edge: GraphEdge, index: GraphIndex, findings: Find switch (edge.type) { case "satisfies": + case "references": requireClaim("anchored"); requireEndpoints(["CodeNode"], "Primitive"); return; @@ -505,6 +510,7 @@ const claimSeparationAnchor = codeAnchor({ label: "checks graph claims, descriptors, node shapes, and relation endpoint contracts", satisfies: ref("spec:validation.claim-separation"), component: componentAnchorId("component:protocol.validate"), + role: "validator", }); void claimSeparationAnchor; @@ -558,7 +564,12 @@ function checkClaimSeparation(graph: GraphSchema, index: GraphIndex): readonly F return findings; } -/* ----- conformance/structural-anchors (`spec:decisions.structural-anchor-semantics`) ----- */ +/* ----- conformance/structural-anchors (`spec:decisions.architectural-annotation`, `spec:decisions.anchor-binding-grain`) ----- */ + +/** The anchored edges the structural check owns: CodeNode structure plus the design reference. */ +function isStructuralEdge(edge: GraphEdge): boolean { + return edge.type === "memberOf" || edge.type === "uses" || edge.type === "references"; +} function structuralFinding( edge: GraphEdge, @@ -587,17 +598,68 @@ function namespaceOf(id: string): string | undefined { } function checkStructuralAnchors(graph: GraphSchema, index: GraphIndex): readonly Finding[] { - const structuralEdges = graph.edges.filter( - (edge) => edge.type === "memberOf" || edge.type === "uses", - ); + const structuralEdges = graph.edges.filter(isStructuralEdge); const findings: Finding[] = []; const exactCounts = new Map(); const membershipsBySource = new Map(); + const satisfiedBySource = new Map>(); + + for (const edge of graph.edges) { + if (edge.type === "satisfies") { + const targets = satisfiedBySource.get(edge.from) ?? new Set(); + targets.add(edge.to); + satisfiedBySource.set(edge.from, targets); + } + } for (const edge of structuralEdges) { const key = `${edge.from}\u0000${edge.type}\u0000${edge.to}`; exactCounts.set(key, (exactCounts.get(key) ?? 0) + 1); + if (edge.type === "references") { + const fromNamespace = namespaceOf(edge.from); + + if ( + fromNamespace === undefined || + !CODE_ANCHOR_NAMESPACES.includes(fromNamespace as (typeof CODE_ANCHOR_NAMESPACES)[number]) + ) { + findings.push( + structuralFinding( + edge, + index, + `A references edge source must use an impl:, api:, or component: CodeNode id; received "${edge.from}".`, + "references", + ), + ); + } + + if (namespaceOf(edge.to) !== "spec") { + findings.push( + structuralFinding( + edge, + index, + `A references edge must target a Spec id; received "${edge.to}".`, + "references", + ), + ); + } + + // A realized target is not also a design reference: the same Spec in `satisfies` and + // `references` on one anchor is an authoring contradiction, not two facts. + if (satisfiedBySource.get(edge.from)?.has(edge.to) === true) { + findings.push( + structuralFinding( + edge, + index, + `Spec "${edge.to}" is named in both "satisfies" and "references" of "${edge.from}"; a realized target is not also a design reference.`, + "references", + ), + ); + } + + continue; + } + if (edge.from === edge.to) { findings.push( structuralFinding( @@ -717,9 +779,7 @@ function checkStructuralAnchors(graph: GraphSchema, index: GraphIndex): readonly */ export function validateStructuralAnchorEdges(graph: GraphSchema): readonly Finding[] { const index = buildGraphIndex(graph); - const structuralEdges = graph.edges.filter( - (edge) => edge.type === "memberOf" || edge.type === "uses", - ); + const structuralEdges = graph.edges.filter(isStructuralEdge); return sortFindings([ ...checkEdgeReferentialIntegrity(structuralEdges, index), @@ -741,6 +801,7 @@ const verifiesLinkageAnchor = codeAnchor({ label: "resolves declared example verification through enabled test bindings", satisfies: ref("spec:validation.verification-linkage"), component: componentAnchorId("component:protocol.validate"), + role: "validator", }); void verifiesLinkageAnchor; @@ -749,6 +810,7 @@ const unboundExamplePostureAnchor = codeAnchor({ label: "limits unbound example warnings to stated ready", satisfies: ref("spec:validation.unbound-example-posture"), component: componentAnchorId("component:protocol.validate"), + role: "validator", }); void unboundExamplePostureAnchor; @@ -814,6 +876,7 @@ const oracleLinkageAnchor = codeAnchor({ label: "resolves and de-duplicates expected-outcome oracle bindings", satisfies: ref("spec:validation.verification-linkage"), component: componentAnchorId("component:protocol.validate"), + role: "validator", }); void oracleLinkageAnchor; @@ -997,6 +1060,7 @@ const packCoherenceAnchor = codeAnchor({ label: "checks Pack membership agreement and uniqueness and model-reference kinds", satisfies: ref("spec:validation.pack-coherence"), component: componentAnchorId("component:protocol.validate"), + role: "validator", }); void packCoherenceAnchor; @@ -1022,6 +1086,7 @@ const orphanSignalAnchor = codeAnchor({ label: "surfaces relationless Specs as informative orphan warnings", satisfies: ref("spec:validation.warn-level-signals"), component: componentAnchorId("component:protocol.validate"), + role: "validator", }); void orphanSignalAnchor; @@ -1258,6 +1323,7 @@ const proseMentionsAnchor = codeAnchor({ label: "checks that prose mentions resolve and warns once per unbacked Spec pair", satisfies: ref("spec:validation.prose-mentions"), component: componentAnchorId("component:protocol.validate"), + role: "validator", }); void proseMentionsAnchor; @@ -1373,6 +1439,7 @@ const authoringShapeAnchor = codeAnchor({ label: "refuses delivery facts smuggled through authored section carriers", satisfies: ref("spec:validation.authored-honesty"), component: componentAnchorId("component:protocol.validate"), + role: "validator", }); void authoringShapeAnchor; @@ -1441,6 +1508,7 @@ const deliveryFactsHonestyAnchor = codeAnchor({ satisfies: ref("spec:validation.authored-honesty"), component: componentAnchorId("component:protocol.validate"), uses: [codeAnchorId("impl:protocol.delivery-facts")], + role: "validator", }); void deliveryFactsHonestyAnchor; @@ -1549,6 +1617,7 @@ const gapSignalAnchor = codeAnchor({ label: "surfaces ready Specs without recomputed verifier bindings as informative gaps", satisfies: ref("spec:validation.warn-level-signals"), component: componentAnchorId("component:protocol.validate"), + role: "validator", }); void gapSignalAnchor; diff --git a/test/anchor-corpora.test.ts b/test/anchor-corpora.test.ts new file mode 100644 index 00000000..014f2bb8 --- /dev/null +++ b/test/anchor-corpora.test.ts @@ -0,0 +1,136 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { afterAll, describe, expect, it } from "vitest"; + +import { + extract, + extractFindingIds, + graphValidatorIds, + serializeGraph, + validateGraph, +} from "../src/index.js"; +import type { Finding } from "../src/index.js"; +import { materializeExtractCorpus, removeMaterializedCorpus } from "./helpers/extract-corpus.js"; + +const materializedRoots: string[] = []; + +function corpusRoot(name: string): string { + const root = materializeExtractCorpus(name); + materializedRoots.push(root); + return root; +} + +function pin(finding: Finding): readonly (string | number | null)[] { + return [ + finding.file ?? null, + finding.line ?? null, + finding.validatorId, + finding.path ?? null, + finding.relatedId ?? null, + ]; +} + +afterAll(() => { + for (const root of materializedRoots) { + removeMaterializedCorpus(root); + } +}); + +describe("the annotation corpora", () => { + it("annotation-forms: a comment-form file that imports nothing and a constant-form file on the anchors subpath derive one clean graph", () => { + const result = extract({ root: corpusRoot("annotation-forms") }); + + expect(result.report.findings).toEqual([]); + expect(result.counts).toEqual({ specs: 4, packs: 0, anchors: 8 }); + expect(validateGraph(result.graph).findings).toEqual([]); + + const goldenPath = fileURLToPath( + new URL("./fixtures/extract/annotation-forms/expected-graph.json", import.meta.url), + ); + expect(serializeGraph(result.graph)).toBe(readFileSync(goldenPath, "utf8")); + + // Plural targets: one satisfies edge per target; the decision reached by references alone. + expect( + result.graph.edges.filter( + (edge) => edge.from === "impl:platform.rebuild" && edge.type === "satisfies", + ), + ).toHaveLength(2); + expect( + result.graph.edges + .filter((edge) => edge.to === "spec:decisions.history-posture") + .map((edge) => [edge.from, edge.type, edge.claim]) + .sort(), + ).toEqual([ + ["impl:platform.gate", "references", "anchored"], + ["impl:platform.rebuild", "references", "anchored"], + ]); + expect( + result.graph.nodes.find((node) => node.id === "spec:decisions.history-posture"), + ).not.toHaveProperty("deliveryFacts"); + expect(result.graph.nodes.find((node) => node.id === "component:platform.read-model")).toEqual( + expect.objectContaining({ layer: "application", context: "platform" }), + ); + expect(result.graph.nodes.find((node) => node.id === "impl:platform.barrel")).toEqual( + expect.objectContaining({ role: "barrel", file: "src/constant-form.ts" }), + ); + }); + + it("annotation-refusals: every refusal of the two forms reports once and withholds its anchor", () => { + const result = extract({ root: corpusRoot("annotation-refusals") }); + const envelope = extractFindingIds.nonStaticEnvelope; + + expect(result.counts).toEqual({ specs: 4, packs: 0, anchors: 0 }); + expect(result.graph.nodes.filter((node) => node.nodeType !== "Primitive")).toEqual([]); + expect(result.report.findings.map(pin)).toEqual([ + ["src/comment-refusals.ts", 3, envelope, "sdpStatus", null], + ["src/comment-refusals.ts", 7, envelope, "id", null], + ["src/comment-refusals.ts", 15, envelope, "role", null], + ["src/comment-refusals.ts", 19, envelope, "verifies", null], + ["src/comment-refusals.ts", 21, envelope, "satisfies", null], + ["src/comment-refusals.ts", 27, envelope, "verifies", null], + ["src/comment-refusals.ts", 32, extractFindingIds.invalidId, "id", null], + ["src/comment-refusals.ts", 37, extractFindingIds.invalidId, "id", null], + ["src/comment-refusals.ts", 43, envelope, "layer", null], + ["src/comment-refusals.ts", 49, envelope, "layer", null], + ["src/comment-refusals.ts", 50, envelope, "context", null], + ["src/comment-refusals.ts", 56, envelope, "satisfies[1]", null], + ["src/comment-refusals.ts", 57, envelope, null, null], + ["src/comment-refusals.ts", 63, envelope, "uses[1]", null], + ["src/comment-refusals.ts", 69, envelope, "models", null], + ["src/comment-refusals.ts", 75, envelope, "role", null], + ["src/comment-refusals.ts", 79, envelope, "verifies", null], + [ + "src/comment-refusals.ts", + 88, + graphValidatorIds.structuralAnchors, + "references", + "spec:platform.gate", + ], + ["src/comment-refusals.ts", 93, envelope, null, null], + ["src/constant-refusals.ts", 5, envelope, "satisfies", null], + ["src/constant-refusals.ts", 10, envelope, "satisfies", null], + ["src/constant-refusals.ts", 15, envelope, "layer", null], + ["src/constant-refusals.ts", 20, envelope, "context", null], + ["src/constant-refusals.ts", 25, envelope, "role", null], + ["src/constant-refusals.ts", 30, envelope, "role", null], + ["src/constant-refusals.ts", 35, envelope, "verifies", null], + ]); + + const messageAt = (line: number): string => + result.report.findings.find( + (finding) => finding.file === "src/comment-refusals.ts" && finding.line === line, + )?.message ?? ""; + expect(messageAt(3)).toContain("not a reserved anchor tag"); + expect(messageAt(7)).toContain("must open its anchor with @sdpAnchor"); + expect(messageAt(15)).toContain("authored more than once"); + expect(messageAt(21)).toContain("outside the binding contract (id · verifies · label)"); + expect(messageAt(43)).toContain("the layer set is closed"); + expect(messageAt(49)).toContain("belongs to a component: anchor only"); + expect(messageAt(57)).toContain("wrapped target is refused"); + expect(messageAt(63)).toContain("empty list item"); + expect(messageAt(69)).toContain("exactly one is required"); + expect(messageAt(75)).toContain("vocabulary, never prose"); + expect(messageAt(93)).toContain("misplaced reserved tag"); + }); +}); diff --git a/test/anchor-forms.test.ts b/test/anchor-forms.test.ts new file mode 100644 index 00000000..073960c0 --- /dev/null +++ b/test/anchor-forms.test.ts @@ -0,0 +1,448 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, describe, expect, it } from "vitest"; + +import { extract, extractFindingIds, serializeGraph, validateGraph } from "../src/index.js"; +import type { GraphSchema } from "../src/index.js"; + +const temporaryRoots: string[] = []; + +const TARGET_SPECS = ["rebuild", "history-rebuild", "gate"] as const; + +function specSource(name: string): string { + return `--- +id: spec:fixture.${name} +kind: behavior +altitude: feature +readiness: idea +relations: {} +--- +# ${name} + +## Intent +- outcome: Exercise the two anchor forms. +`; +} + +/** One root per source file: the Specs every binding targets, plus the file under test. */ +function fixtureRoot(fileName: string, source: string): string { + const root = mkdtempSync(join(tmpdir(), "sdp-anchor-forms-")); + temporaryRoots.push(root); + mkdirSync(join(root, "specs")); + + for (const name of TARGET_SPECS) { + writeFileSync(join(root, "specs", `${name}.sdp.md`), specSource(name), "utf8"); + } + + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src", fileName), source, "utf8"); + return root; +} + +/** The graph minus binding sites: `file` and `line` locate an anchor, they are not its content. */ +function siteless(graph: GraphSchema): unknown { + const serialized = JSON.parse(serializeGraph(graph)) as GraphSchema; + + return { + nodes: serialized.nodes.map((node) => + node.nodeType === "CodeNode" || node.nodeType === "Anchor" + ? { ...node, file: undefined, line: undefined } + : node, + ), + edges: serialized.edges, + }; +} + +function codeNodeIds(graph: GraphSchema): readonly string[] { + return graph.nodes.filter((node) => node.nodeType === "CodeNode").map((node) => node.id); +} + +afterAll(() => { + for (const root of temporaryRoots) { + rmSync(root, { recursive: true, force: true }); + } +}); + +const CONSTANT_FORM = `import { codeAnchor, codeAnchorId, componentAnchorId, ref, specTest, testAnchorId } from "@libar-dev/software-delivery-protocol/anchors"; + +export const readModel = codeAnchor({ + id: codeAnchorId("component:fixture.read-model"), + label: "the read model", + layer: "application", + context: "platform", +}); + +export const rebuild = codeAnchor({ + id: codeAnchorId("impl:fixture.rebuild"), + label: "the online rebuild path", + satisfies: [ref("spec:fixture.rebuild")], + references: [ref("spec:fixture.history-rebuild")], + component: componentAnchorId("component:fixture.read-model"), + uses: [codeAnchorId("impl:fixture.gate"), componentAnchorId("component:fixture.read-model")], + role: "service", +}); + +export const gate = codeAnchor({ + id: codeAnchorId("impl:fixture.gate"), + satisfies: ref("spec:fixture.gate"), +}); + +export const rebuildTest = specTest({ + id: testAnchorId("test:fixture.rebuild"), + label: "rebuild is verified", + verifies: [ref("spec:fixture.rebuild"), ref("spec:fixture.gate")], +}); +`; + +const COMMENT_FORM = `/** + * The read model. Prose stays local commentary and confers nothing. + * + * @sdpAnchor component:fixture.read-model + * @sdpLabel the read model + * @sdpLayer application + * @sdpContext platform + */ +export const readModel = {}; + +/** + * @sdpAnchor impl:fixture.rebuild + * @sdpLabel the online rebuild path + * @sdpSatisfies spec:fixture.rebuild + * @sdpReferences spec:fixture.history-rebuild + * @sdpComponent component:fixture.read-model + * @sdpUses impl:fixture.gate, component:fixture.read-model + * @sdpRole service + */ +export async function rebuild(): Promise {} + +/** @sdpAnchor impl:fixture.gate + * @sdpSatisfies spec:fixture.gate */ +export function gate(): void {} + +/** + * @sdpAnchor test:fixture.rebuild + * @sdpLabel rebuild is verified + * @sdpVerifies spec:fixture.rebuild, spec:fixture.gate + */ +export function rebuildTest(): void {} +`; + +describe("the two anchor forms", () => { + it("reifies the constant form with plural targets through the anchors subpath", () => { + const result = extract({ root: fixtureRoot("constant.ts", CONSTANT_FORM) }); + + expect(result.report.findings).toEqual([]); + expect(result.counts.anchors).toBe(4); + expect(result.graph.edges.filter((edge) => edge.from === "impl:fixture.rebuild")).toEqual([ + { + from: "impl:fixture.rebuild", + type: "satisfies", + to: "spec:fixture.rebuild", + claim: "anchored", + }, + { + from: "impl:fixture.rebuild", + type: "memberOf", + to: "component:fixture.read-model", + claim: "anchored", + }, + { from: "impl:fixture.rebuild", type: "uses", to: "impl:fixture.gate", claim: "anchored" }, + { + from: "impl:fixture.rebuild", + type: "uses", + to: "component:fixture.read-model", + claim: "anchored", + }, + { + from: "impl:fixture.rebuild", + type: "references", + to: "spec:fixture.history-rebuild", + claim: "anchored", + }, + ]); + expect(result.graph.edges.filter((edge) => edge.from === "test:fixture.rebuild")).toEqual([ + { + from: "test:fixture.rebuild", + type: "verifies", + to: "spec:fixture.rebuild", + claim: "anchored", + }, + { + from: "test:fixture.rebuild", + type: "verifies", + to: "spec:fixture.gate", + claim: "anchored", + }, + ]); + // An identity-only component anchor mints a CodeNode, with its layer and context, and no edge. + expect(result.graph.nodes).toContainEqual({ + id: "component:fixture.read-model", + nodeType: "CodeNode", + claim: "anchored", + label: "the read model", + file: "src/constant.ts", + line: 3, + layer: "application", + context: "platform", + }); + expect(result.graph.edges.some((edge) => edge.from === "component:fixture.read-model")).toBe( + false, + ); + expect(result.graph.nodes).toContainEqual( + expect.objectContaining({ id: "impl:fixture.rebuild", role: "service" }), + ); + // `references` confers nothing: only the satisfied Spec reads implemented. + const primitive = (id: string) => + result.graph.nodes.find((node) => node.id === id && node.nodeType === "Primitive"); + expect(primitive("spec:fixture.rebuild")).toEqual( + expect.objectContaining({ deliveryFacts: ["implemented", "has-verifier"] }), + ); + expect(primitive("spec:fixture.history-rebuild")).not.toHaveProperty("deliveryFacts"); + expect(validateGraph(result.graph).findings).toEqual([]); + }); + + it("reifies the comment form from a file that imports nothing, at the block's first line", () => { + const result = extract({ root: fixtureRoot("comment.ts", COMMENT_FORM) }); + + expect(result.report.findings).toEqual([]); + expect(result.counts.anchors).toBe(4); + expect(result.graph.nodes.filter((node) => node.nodeType !== "Primitive")).toEqual([ + { + id: "component:fixture.read-model", + nodeType: "CodeNode", + claim: "anchored", + label: "the read model", + file: "src/comment.ts", + line: 1, + layer: "application", + context: "platform", + }, + { + id: "impl:fixture.rebuild", + nodeType: "CodeNode", + claim: "anchored", + label: "the online rebuild path", + file: "src/comment.ts", + line: 11, + role: "service", + }, + { + id: "impl:fixture.gate", + nodeType: "CodeNode", + claim: "anchored", + file: "src/comment.ts", + line: 22, + }, + { + id: "test:fixture.rebuild", + nodeType: "Anchor", + claim: "anchored", + label: "rebuild is verified", + file: "src/comment.ts", + line: 26, + }, + ]); + expect(validateGraph(result.graph).findings).toEqual([]); + }); + + it("gives identical graph data to identical content in either form", () => { + const constant = extract({ root: fixtureRoot("binding.ts", CONSTANT_FORM) }); + const comment = extract({ root: fixtureRoot("binding.ts", COMMENT_FORM) }); + + expect(constant.report.findings).toEqual([]); + expect(comment.report.findings).toEqual([]); + expect(siteless(comment.graph)).toEqual(siteless(constant.graph)); + }); + + it("reads every top-level /** block: above an import, several before one statement, after the last, alone in a file", () => { + const result = extract({ + root: fixtureRoot( + "attachment.ts", + `/** @sdpAnchor impl:fixture.above-import */ +import { readFileSync } from "node:fs"; + +/** @sdpAnchor impl:fixture.first */ +/** @sdpAnchor impl:fixture.second */ +export const pair = readFileSync; +/** @sdpAnchor impl:fixture.trailing */ + +/** + * Ordinary documentation with a TSDoc tag and no reserved tag. + * @param nothing - is read here + */ +export function plain(): void {} + +/** @sdpAnchor impl:fixture.tail */ +`, + ), + }); + const alone = extract({ + root: fixtureRoot("alone.ts", `/** @sdpAnchor impl:fixture.alone\n * @sdpRole barrel */\n`), + }); + + expect(result.report.findings).toEqual([]); + expect(codeNodeIds(result.graph)).toEqual([ + "impl:fixture.above-import", + "impl:fixture.first", + "impl:fixture.second", + "impl:fixture.trailing", + "impl:fixture.tail", + ]); + expect(alone.report.findings).toEqual([]); + expect(alone.graph.nodes).toContainEqual( + expect.objectContaining({ id: "impl:fixture.alone", role: "barrel", line: 1 }), + ); + }); + + it("refuses a reserved tag in a nested position and never reads a non-doc comment", () => { + const result = extract({ + root: fixtureRoot( + "nested.ts", + `// @sdpAnchor impl:fixture.line-comment +/* @sdpAnchor impl:fixture.plain-block */ +export function outer(): void { + /** + * @sdpAnchor impl:fixture.nested + */ + const inner = 1; + void inner; +} + +export class Holder { + /** @sdpAnchor impl:fixture.member */ + method(): void {} +} + +export const literal = { + /** @sdpReferences spec:fixture.gate */ + key: 1, +}; + +export function quiet(): void { + // @sdpAnchor impl:fixture.nested-line-comment + /* @sdpAnchor impl:fixture.nested-plain-block */ + /** Explain @sdpAnchor here: prose that mentions a tag reads nothing. */ +} + +export function empty(): void { /** @sdpAnchor impl:fixture.empty-body */ } + +export class Closing { + method(): void {} + /** @sdpAnchor impl:fixture.before-class-close */ +} + +export function afterLast(): void { + void 0; + /** @sdpAnchor impl:fixture.after-last-statement */ +} +`, + ), + }); + + expect(result.report.findings).toEqual( + [4, 12, 17, 27, 31, 36].map((line): unknown => + expect.objectContaining({ + validatorId: extractFindingIds.nonStaticEnvelope, + line, + message: expect.stringContaining("misplaced reserved tag") as string, + }), + ), + ); + expect(codeNodeIds(result.graph)).toEqual([]); + }); + + it("refuses a reserved tag in a JSX expression and reads nothing from JSX text", () => { + const result = extract({ + root: fixtureRoot( + "view.tsx", + `export function View(): unknown { + return ( +
+ {/** @sdpAnchor impl:fixture.jsx-expression */} + /** @sdpAnchor impl:fixture.jsx-text */ +
+ ); +} +`, + ), + }); + + expect(result.report.findings).toEqual([ + expect.objectContaining({ + validatorId: extractFindingIds.nonStaticEnvelope, + line: 4, + message: expect.stringContaining("misplaced reserved tag") as string, + }), + ]); + expect(codeNodeIds(result.graph)).toEqual([]); + }); + + it("refuses a misspelled reserved tag rather than reading it as prose", () => { + const result = extract({ + root: fixtureRoot( + "misspelled.ts", + `/** @sdp-anchor impl:fixture.hyphenated */ +export const hyphenated = 1; + +/** @sdpAnchor: impl:fixture.colon */ +export const colon = 1; + +/** + * @sdpBad: value + * @sdpAnchor impl:fixture.gate + * @sdpSatisfies spec:fixture.gate + */ +export const prefixed = 1; +`, + ), + }); + const malformed = (line: number, tag: string): unknown => + expect.objectContaining({ + validatorId: extractFindingIds.nonStaticEnvelope, + line, + message: expect.stringContaining(`malformed reserved tag "@${tag}"`) as string, + }); + const missingOpener = (line: number): unknown => + expect.objectContaining({ + validatorId: extractFindingIds.nonStaticEnvelope, + line, + message: expect.stringContaining("must open its anchor with @sdpAnchor") as string, + }); + + expect(result.report.findings).toEqual([ + malformed(1, "sdp-anchor"), + missingOpener(1), + malformed(4, "sdpAnchor:"), + missingOpener(4), + malformed(8, "sdpBad:"), + ]); + expect(codeNodeIds(result.graph)).toEqual([]); + }); + + it("reports the same duplicate id across the two forms", () => { + const result = extract({ + root: fixtureRoot( + "both.ts", + `import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol"; + +/** + * @sdpAnchor impl:fixture.gate + * @sdpSatisfies spec:fixture.gate + */ +export const gate = codeAnchor({ + id: codeAnchorId("impl:fixture.gate"), + satisfies: ref("spec:fixture.gate"), +}); +`, + ), + }); + + expect(result.report.findings.map((finding) => [finding.validatorId, finding.line])).toEqual([ + [extractFindingIds.duplicateId, 3], + [extractFindingIds.duplicateId, 7], + ]); + expect(result.graph.nodes.some((node) => node.id === "impl:fixture.gate")).toBe(false); + }); +}); diff --git a/test/anchor-trust.test.ts b/test/anchor-trust.test.ts index 375b2b8f..ba2a257f 100644 --- a/test/anchor-trust.test.ts +++ b/test/anchor-trust.test.ts @@ -100,6 +100,32 @@ void binding; ); }); + it("trusts the zero-dependency anchors subpath exactly as the bare specifier", () => { + const root = temporaryRoot("anchors-subpath"); + writeFileSync( + join(root, "binding.ts"), + `import { codeAnchor, codeAnchorId, ref } from "@libar-dev/software-delivery-protocol/anchors"; + +const binding = codeAnchor({ + id: codeAnchorId("impl:consumer.subpath-binding"), + satisfies: ref("spec:consumer.subpath-binding"), +}); +void binding; +`, + ); + + const result = extract({ root }); + + expect(result.report.findings).toEqual([]); + expect(result.counts.anchors).toBe(1); + expect(result.graph.edges).toContainEqual({ + from: "impl:consumer.subpath-binding", + type: "satisfies", + to: "spec:consumer.subpath-binding", + claim: "anchored", + }); + }); + it("continues to recognize package imports in consumer repositories", () => { const root = temporaryRoot("package-import"); writeFileSync( diff --git a/test/anchors-subpath.test.ts b/test/anchors-subpath.test.ts new file mode 100644 index 00000000..2971a221 --- /dev/null +++ b/test/anchors-subpath.test.ts @@ -0,0 +1,226 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = fileURLToPath(new URL("..", import.meta.url)); +const builtSubpath = resolve(repositoryRoot, "dist/anchors.js"); + +/** + * Every runtime import form, read from the syntax tree rather than the text: a side-effect + * `import "x"`, `import ... from "x"`, `export ... from "x"`, `export * from "x"`, `import("x")` + * with or without import attributes, and `require("x")`, in source order. A type-only import or + * export is erased by the build and is no runtime dependency. A call spelled inside a string, a + * template, or a comment is text, not a call, so it is never read as one. + */ +function moduleSpecifiers(file: string, text: string): readonly string[] { + const specifiers: string[] = []; + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node)) { + if ( + node.importClause?.phaseModifier !== ts.SyntaxKind.TypeKeyword && + ts.isStringLiteralLike(node.moduleSpecifier) + ) { + specifiers.push(node.moduleSpecifier.text); + } + } else if (ts.isExportDeclaration(node)) { + if ( + !node.isTypeOnly && + node.moduleSpecifier !== undefined && + ts.isStringLiteralLike(node.moduleSpecifier) + ) { + specifiers.push(node.moduleSpecifier.text); + } + } else if (ts.isCallExpression(node)) { + const callee = node.expression; + const [argument] = node.arguments; + + if ( + argument !== undefined && + ts.isStringLiteralLike(argument) && + (callee.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(callee) && callee.text === "require")) + ) { + specifiers.push(argument.text); + } + } + + ts.forEachChild(node, visit); + }; + + visit(ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)); + + return specifiers; +} + +/** A relative specifier stays inside the package; every other specifier is a dependency. */ +function isRelative(specifier: string): boolean { + return specifier.startsWith("."); +} + +/** Every module specifier the entry file and its relative imports reach, in discovery order. */ +function importClosure(entry: string): readonly string[] { + const specifiers: string[] = []; + const visited = new Set(); + const queue = [entry]; + + for (let file = queue.shift(); file !== undefined; file = queue.shift()) { + if (visited.has(file)) { + continue; + } + + visited.add(file); + + for (const specifier of moduleSpecifiers(file, readFileSync(file, "utf8"))) { + specifiers.push(specifier); + + if (isRelative(specifier)) { + queue.push(resolve(dirname(file), specifier)); + } + } + } + + return specifiers; +} + +describe("the anchors subpath", () => { + it("builds to a module whose import closure is relative only: no node: specifier, no ts-morph, no dependency", async () => { + if (!existsSync(builtSubpath)) { + execFileSync("npm", ["run", "build"], { cwd: repositoryRoot, encoding: "utf8" }); + } + + const closure = importClosure(builtSubpath); + expect(closure.filter((specifier) => !isRelative(specifier))).toEqual([]); + expect(readFileSync(builtSubpath, "utf8")).not.toMatch(/\bts-morph\b|["']node:/u); + + const subpath = (await import(pathToFileURL(builtSubpath).href)) as Record; + expect(Object.keys(subpath).sort()).toEqual([ + "CODE_ANCHOR_LAYERS", + "CODE_ANCHOR_NAMESPACES", + "anchorId", + "codeAnchor", + "codeAnchorId", + "componentAnchorId", + "oracleAnchorId", + "packId", + "ref", + "specId", + "specOracle", + "specTest", + "testAnchorId", + ]); + }); + + it("is published as an ESM subpath with its own types", () => { + const manifest = JSON.parse(readFileSync(resolve(repositoryRoot, "package.json"), "utf8")) as { + readonly exports: Record>; + }; + + expect(manifest.exports["./anchors"]).toEqual({ + types: "./dist/anchors.d.ts", + import: "./dist/anchors.js", + }); + }); +}); + +describe("the import scanner behind the subpath check", () => { + // Given: a tree whose entry carries a direct side-effect import and a named external import, + // and whose relative chain reaches a side-effect import two files deep, with the dynamic and + // CommonJS forms on the way. When: the closure is scanned. Then: every form is listed in + // discovery order, and the relative-only predicate rejects each external specifier. + it("lists side-effect, transitive, and named external imports that the relative-only predicate rejects", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-anchors-closure-")); + + try { + writeFileSync( + join(root, "entry.js"), + [ + 'import "reflect-metadata";', + 'import { pad } from "left-pad";', + 'import "./middle.js";', + 'export const label = "import from nowhere";', + "export const here = import.meta.url;", + "export const entry = pad;", + "", + ].join("\n"), + "utf8", + ); + writeFileSync( + join(root, "middle.js"), + ['export * from "./leaf.js";', 'export const lazy = () => import("./lazy.js");', ""].join( + "\n", + ), + "utf8", + ); + writeFileSync( + join(root, "leaf.js"), + ['import "core-js/stable";', "export const leaf = 1;", ""].join("\n"), + "utf8", + ); + writeFileSync(join(root, "lazy.js"), 'module.exports = require("node:crypto");\n', "utf8"); + + const closure = importClosure(join(root, "entry.js")); + + expect(closure).toEqual([ + "reflect-metadata", + "left-pad", + "./middle.js", + "./leaf.js", + "./lazy.js", + "core-js/stable", + "node:crypto", + ]); + expect(closure.filter((specifier) => !isRelative(specifier))).toEqual([ + "reflect-metadata", + "left-pad", + "core-js/stable", + "node:crypto", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + // Given: the forms a token scan misses (a space before the parenthesis, a comment between the + // keyword and the specifier, import attributes) beside text that spells a call without making + // one (a string, a template, a comment, `import.meta`). When: the closure is scanned. Then: the + // four calls are listed in source order, nothing else is, and the predicate rejects each. + it("reads a spaced, commented, or attributed form, and never a call spelled in a string", () => { + const root = mkdtempSync(join(tmpdir(), "sdp-anchors-forms-")); + + try { + writeFileSync( + join(root, "entry.js"), + [ + 'export const spaced = () => import ("spaced-dynamic");', + 'const required = require ("spaced-require");', + 'import/* between the keyword and the specifier */"commented-side-effect";', + 'export const attributed = () => import("attributed-json", { with: { type: "json" } });', + "export const quoted = 'require(\"quoted-text\")';", + 'export const templated = `import("templated-text")`;', + '// import "commented-out";', + "export const here = import.meta.url;", + "export const entry = required;", + "", + ].join("\n"), + "utf8", + ); + + const closure = importClosure(join(root, "entry.js")); + + expect(closure).toEqual([ + "spaced-dynamic", + "spaced-require", + "commented-side-effect", + "attributed-json", + ]); + expect(closure.filter((specifier) => !isRelative(specifier))).toEqual(closure); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/builders.typecheck.ts b/test/builders.typecheck.ts index c981df5a..8948c055 100644 --- a/test/builders.typecheck.ts +++ b/test/builders.typecheck.ts @@ -155,3 +155,42 @@ pack({ // @ts-expect-error packs carry no constraints section — constraints are authored on a Spec constraints: [{ statement: "p95 < 200ms" }], }); + +// The binding grain (`spec:decisions.anchor-binding-grain`): `satisfies` is optional and plural, +// `verifies` is plural, `references` is a non-conferring list, and `role` / `layer` / `context` +// are the closed structural attributes of the architectural annotation decision. +const identityOnlyAnchor = codeAnchor({ + id: codeAnchorId("impl:orders.identity-only"), + role: "barrel", +}); + +const pluralAnchor = codeAnchor({ + id: codeAnchorId("impl:orders.plural"), + satisfies: [ref("spec:orders.create-order"), ref("spec:orders.order-total-rule")], + references: [ref("spec:orders.history-view")], +}); + +const componentAnchor = codeAnchor({ + id: codeAnchorId("component:orders.core"), + layer: "domain", + context: "orders", +}); + +const pluralVerifier = specTest({ + id: testAnchorId("test:orders.create-order.plural"), + verifies: [ref("spec:orders.create-order"), ref("spec:orders.create-order.valid-cart")], +}); + +void [identityOnlyAnchor, pluralAnchor, componentAnchor, pluralVerifier]; + +codeAnchor({ + id: codeAnchorId("component:orders.core"), + // @ts-expect-error layer is a closed set: edge · application · domain · adapter · infrastructure + layer: "presentation", +}); + +codeAnchor({ + id: codeAnchorId("impl:orders.create-order-use-case"), + // @ts-expect-error references targets are Spec ids, never code anchor ids + references: [codeAnchorId("impl:orders.plural")], +}); diff --git a/test/census.test.ts b/test/census.test.ts index 0c623606..142cd4c4 100644 --- a/test/census.test.ts +++ b/test/census.test.ts @@ -34,6 +34,7 @@ import { runSdpCli } from "../src/cli/sdp.js"; import type { CensusPage, Finding, GraphSchema, Reader, SpecSummary } from "../src/index.js"; import { createCaptureOutput } from "./helpers/cli-capture.js"; import { materializeExtractCorpus, removeMaterializedCorpus } from "./helpers/extract-corpus.js"; +import { countMapSets } from "./helpers/map-sets.js"; const repoRoot = fileURLToPath(new URL("..", import.meta.url)); const exampleRoot = join(repoRoot, "examples", "checkout-v1"); @@ -445,6 +446,186 @@ export const anchor = codeAnchor({ expect(page).toContain("| CodeNode | `impl` | `satisfies` | 1 |"); expect(page).toContain("| CodeNode | `impl` | unrecognized: `connects` | 1 |"); }); + + it("renders the role, layer, and context taxonomies and the references table from the graph", () => { + const root = materializeExtractCorpus("annotation-forms"); + + try { + const reader = createReader(extract({ root }).graph); + const page = pageByPath(renderCensus(reader), "index.md"); + + // Units are counted apart from edges. + expect(page).toContain("| Code units | 5 |"); + expect(page).toContain("| Component units | 2 |"); + expect(page).toContain("| `memberOf` edges | 2 |"); + expect(page).toContain("| `uses` edges | 2 |"); + expect(page).toContain("| `references` edges | 3 |"); + expect(page).not.toContain("No structural bindings exist."); + + // Each value links to its units, so an owner can reconcile synonyms; the closed layer set + // renders every value even at zero. + expect(page).toContain("### Layers"); + expect(page).toContain("| `edge` | `component:platform.gateway` | 1 |"); + expect(page).toContain("| `application` | `component:platform.read-model` | 1 |"); + expect(page).toContain("| `infrastructure` | — | 0 |"); + expect(page).toContain("### Contexts"); + expect(page).toContain( + "| `platform` | `component:platform.gateway`, `component:platform.read-model` | 2 |", + ); + expect(page).toContain("### Roles"); + expect(page).toContain("| `barrel` | `impl:platform.barrel` | 1 |"); + expect(page).toContain("| `decider` | `impl:platform.gate` | 1 |"); + expect(page).toContain("| `service` | `impl:platform.rebuild` | 1 |"); + expect(page).toContain("### References"); + expect(page).toContain( + "A `references` edge says the unit answers to that design without claiming to realize it; it confers no delivery fact.", + ); + expect(page).toContain( + "| `impl:platform.gate` | `spec:decisions.history-posture`, `spec:platform.rebuild` | 2 |", + ); + expect(page).toContain("| `impl:platform.rebuild` | `spec:decisions.history-posture` | 1 |"); + expect(page).toContain("| CodeNode | `impl` | `references` | 3 |"); + } finally { + removeMaterializedCorpus(root); + } + }); + + it("renders attribute-only structure, a foreign layer value, and empty tables deterministically", () => { + const graph = { + schemaVersion, + nodes: [ + { + id: "impl:probe.codec", + nodeType: "CodeNode", + claim: "anchored", + file: "src/codec.ts", + role: "codec", + }, + { + id: "component:probe.store", + nodeType: "CodeNode", + claim: "anchored", + file: "src/store.ts", + layer: "tier", + }, + ], + edges: [], + } as unknown as GraphSchema; + const page = pageByPath(renderCensus(readerStub({ graph })), "index.md"); + const reordered = pageByPath( + renderCensus(readerStub({ graph: { ...graph, nodes: [...graph.nodes].reverse() } })), + "index.md", + ); + + expect(reordered).toBe(page); + expect(page).not.toContain("No structural bindings exist."); + expect(page).toContain("| Code units | 2 |"); + expect(page).toContain("| `references` edges | 0 |"); + expect(page).toContain("| `codec` | `impl:probe.codec` | 1 |"); + expect(page).toContain("| unrecognized: `tier` | `component:probe.store` | 1 |"); + expect(page).toContain("| `domain` | — | 0 |"); + expect(page).toContain( + "### Contexts\n\n| Context | Units | Unit count |\n| --- | --- | ---: |\n| — | None | 0 |", + ); + expect(page).toContain("### References"); + }); + + it("groups twenty thousand units sharing one role and one context into one bucket each, in id order", () => { + const ids = Array.from( + { length: 20_000 }, + (_unit, index) => `component:probe.unit-${String(index).padStart(5, "0")}`, + ); + const graph: GraphSchema = { + schemaVersion, + // Reverse insertion: the bucket order is the sorted unit order, never the graph's. + nodes: [...ids].reverse().map((id) => ({ + id, + nodeType: "CodeNode" as const, + claim: "anchored" as const, + file: "src/probe.ts", + role: "service", + context: "probe", + })), + edges: [], + }; + const page = pageByPath(renderCensus(readerStub({ graph })), "index.md"); + const units = ids.map((id) => `\`${id}\``).join(", "); + + expect(page).toContain("| Code units | 20000 |"); + expect(page).toContain( + [ + "### Contexts", + "", + "| Context | Units | Unit count |", + "| --- | --- | ---: |", + `| \`probe\` | ${units} | 20000 |`, + "", + "### Roles", + "", + "| Role | Units | Unit count |", + "| --- | --- | ---: |", + `| \`service\` | ${units} | 20000 |`, + "", + "### References", + ].join("\n"), + ); + }); + + it("creates each attribute bucket once, however many units share the value", () => { + const ids = Array.from( + { length: 20_000 }, + (_unit, index) => `component:probe.unit-${String(index).padStart(5, "0")}`, + ); + const graphWithRoles = (roleOf: (index: number) => string): GraphSchema => ({ + schemaVersion, + nodes: ids.map((id, index) => ({ + id, + nodeType: "CodeNode" as const, + claim: "anchored" as const, + file: "src/probe.ts", + role: roleOf(index), + context: "probe", + })), + edges: [], + }); + const setsToRender = (graph: GraphSchema): number => + countMapSets(() => renderCensus(readerStub({ graph }))).sets; + + // Grouping that creates each bucket once pays one Map set for one shared role and one per + // role for 20,000 distinct roles; every other set the census pays is the same for both + // graphs. Grouping that replaced the bucket on every insertion would pay one set per unit + // either way, and the difference would be 0. + const shared = setsToRender(graphWithRoles(() => "service")); + const distinct = setsToRender(graphWithRoles((index) => `role-${String(index)}`)); + + expect(distinct - shared).toBe(ids.length - 1); + }); + + it("keeps the explicit empty state while still counting units apart from edges", () => { + const page = pageByPath( + renderCensus( + readerStub({ + graph: { + schemaVersion, + nodes: [ + { + id: "impl:probe.bare", + nodeType: "CodeNode", + claim: "anchored", + file: "src/bare.ts", + }, + ], + edges: [], + }, + }), + ), + "index.md", + ); + + expect(page).toContain("| Code units | 1 |"); + expect(page).toContain("No structural bindings exist."); + expect(page).not.toContain("### Roles"); + }); }); describe("sdp census publication", () => { diff --git a/test/check-self-hosting-gates.test.ts b/test/check-self-hosting-gates.test.ts index 181f0ea0..3cf8fb16 100644 --- a/test/check-self-hosting-gates.test.ts +++ b/test/check-self-hosting-gates.test.ts @@ -76,10 +76,11 @@ afterEach(() => { }); describe("the self-hosting records gate", () => { - it("discovers plan 39 as the current primary plan", () => { + it("discovers plan 40 as the current primary plan", () => { + // re-measured under plan 40 expect(currentPlan).toMatchObject({ - number: 39, - name: "39-adopter-driven-hardening.md", + number: 40, + name: "40-annotations-closer-to-v0.md", }); }); diff --git a/test/cli.test.ts b/test/cli.test.ts index 79bd8bef..0ef54bb8 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -65,6 +65,21 @@ function materializeSelfHostingCopy(): string { }); } + // Copied relative builder modules are outside the Protocol's trusted source checkout. Use + // the public anchors import in this isolated corpus so both forms retain their bindings. + for (const entry of readdirSync(join(root, "src"), { recursive: true, withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".ts")) continue; + const path = join(entry.parentPath, entry.name); + const source = readFileSync(path, "utf8"); + writeFileSync( + path, + source.replace( + /from "(?:\.\.?\/)+(?:model\/)?(?:ids|code-anchor)\.js"/gu, + 'from "@libar-dev/software-delivery-protocol/anchors"', + ), + ); + } + return root; } @@ -295,7 +310,8 @@ describe("sdp cli", () => { `${file} — [warning] conformance/prose-mentions — Mention of "${target}" in "${specId}"`, ); } - expect(capture.readStdout()).toContain("validate: 0 errors · 13 warnings"); + // re-measured under plan 40 + expect(capture.readStdout()).toContain("validate: 0 errors · 14 warnings"); expect(readFileSync(join(root, "generated", "graph.json"), "utf8")).toContain( '"id": "pack:self-hosting-v1"', ); diff --git a/test/design-review.test.ts b/test/design-review.test.ts index fe8b0c32..bb9bb6c1 100644 --- a/test/design-review.test.ts +++ b/test/design-review.test.ts @@ -7,6 +7,8 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; import { + codeAnchor, + codeAnchorId, createReader, extract, pack, @@ -19,6 +21,7 @@ import { testAnchorId, } from "../src/index.js"; import type { DesignReviewPage, GraphSchema } from "../src/index.js"; +import { materializeExtractCorpus, removeMaterializedCorpus } from "./helpers/extract-corpus.js"; import { deriveFixtureGraph } from "./helpers/fixture-graph.js"; const exampleRoot = fileURLToPath(new URL("../examples/checkout-v1", import.meta.url)); @@ -95,7 +98,7 @@ describe("the Design Review — the one generated read-only view", () => { "## Narrative\n\nThe Protocol's own delivery model exercises the same carrier, graph, checks, and projections offered to consumers.\n\n**Readiness:", ); expect(page).toContain("## Intent\n\n- **outcome:"); - expect(index).toContain("schema `0.7.0`"); + expect(index).toContain("schema `0.8.0`"); expect(pack).toContain( "| [`spec:protocol.self-hosting`](../spec/protocol.self-hosting.md) The Protocol authors and validates itself | behavior | epic | defined | ready | none | none |", ); @@ -649,4 +652,71 @@ describe("the Design Review — the one generated read-only view", () => { expect(page).toContain("`spec:orders.order-management` — **unresolved** (see findings)"); expect(page).toContain("conformance/referential-integrity"); }); + + it("lists the referencing units with file and line, and the reference confers no binding", () => { + const root = materializeExtractCorpus("annotation-forms"); + + try { + const pages = renderDesignReview(createReader(extract({ root }).graph)); + const posture = pageByPath(pages, "spec/decisions.history-posture.md"); + const rebuild = pageByPath(pages, "spec/platform.rebuild.md"); + + expect(posture).toContain("- Implementation binding: **none**"); + expect(posture).not.toContain("### Implementations"); + expect(posture).toContain( + [ + "### Referenced by", + "", + "Code that answers to this design without claiming to realize it. A reference confers no implementation binding.", + "", + "- `impl:platform.gate` — the gate ([src/constant-form.ts:17](../../../src/constant-form.ts)) · role `decider` `[anchored]`", + "- `impl:platform.rebuild` — the online rebuild path ([src/comment-form.ts:13](../../../src/comment-form.ts)) · role `service` `[anchored]`", + ].join("\n"), + ); + + // An implementation row shows the unit's role; the reference rides beside it. + expect(rebuild).toContain( + "- `impl:platform.rebuild` — the online rebuild path ([src/comment-form.ts:13](../../../src/comment-form.ts)) · role `service` `[anchored]`", + ); + expect(rebuild.indexOf("### Implementations")).toBeLessThan( + rebuild.indexOf("### Referenced by"), + ); + } finally { + removeMaterializedCorpus(root); + } + }); + + it("shows a component row's layer and context", () => { + const graph = deriveFixtureGraph({ + specs: [ + spec({ + id: specId("spec:orders.order-management"), + title: "Order management", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "Manage orders." }, + }), + ], + anchors: [ + codeAnchor({ + id: codeAnchorId("component:orders.core"), + label: "the orders core", + satisfies: specId("spec:orders.order-management"), + layer: "domain", + context: "orders", + role: "service", + }), + ], + }); + const page = pageByPath( + renderDesignReview(createReader(graph)), + "spec/orders.order-management.md", + ); + + expect(page).toContain( + "- `component:orders.core` — the orders core ([src/fixture.ts:1](../../../src/fixture.ts)) · role `service` · layer `domain` · context `orders` `[anchored]`", + ); + expect(page).not.toContain("### Referenced by"); + }); }); diff --git a/test/extract.test.ts b/test/extract.test.ts index 4d2bfb23..1706c5a9 100644 --- a/test/extract.test.ts +++ b/test/extract.test.ts @@ -879,8 +879,8 @@ relations: {} 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(pathsByFile.get("bare-binding.ts")).toEqual(["id", "id", "verifies"]); + expect(errors).toHaveLength(9); expect(result.graph.nodes).toEqual([]); expect(result.counts).toEqual({ specs: 0, packs: 0, anchors: 0 }); }); diff --git a/test/fixtures/checkout-v1/expected-census/index.md b/test/fixtures/checkout-v1/expected-census/index.md index 346dfc0a..0cd8f361 100644 --- a/test/fixtures/checkout-v1/expected-census/index.md +++ b/test/fixtures/checkout-v1/expected-census/index.md @@ -1,6 +1,6 @@ # Census -A disposable projection of the one graph (schema `0.7.0`): 17 nodes · 32 edges · 11 Specs. +A disposable projection of the one graph (schema `0.8.0`): 17 nodes · 32 edges · 11 Specs. ## Spec kinds @@ -82,6 +82,7 @@ A disposable projection of the one graph (schema `0.7.0`): 17 nodes · 32 edges | `models` | 1 | | `memberOf` | 0 | | `uses` | 0 | +| `references` | 0 | ## Graph edge claims @@ -104,7 +105,15 @@ Anchor flavor is the binding node type plus its ID namespace plus each outgoing ## Structural bindings -Authored `memberOf` and `uses` CodeNode edges are rendered as structure; they confer no delivery fact or readiness. +Anchored `memberOf`, `uses`, and `references` edges and the `role`, `layer`, and `context` attributes of code units are rendered as structure; they confer no delivery fact or readiness. Units are counted apart from edges. + +| Structure | Count | +| --- | ---: | +| Code units | 3 | +| Component units | 0 | +| `memberOf` edges | 0 | +| `uses` edges | 0 | +| `references` edges | 0 | No structural bindings exist. diff --git a/test/fixtures/checkout-v1/expected-design-review/index.md b/test/fixtures/checkout-v1/expected-design-review/index.md index 8952599d..9e11881a 100644 --- a/test/fixtures/checkout-v1/expected-design-review/index.md +++ b/test/fixtures/checkout-v1/expected-design-review/index.md @@ -1,6 +1,6 @@ # Design Review -The one generated read-only view — a pure projection of the one graph (`graph.json`, schema `0.7.0`): 17 nodes · 32 edges. +The one generated read-only view — a pure projection of the one graph (`graph.json`, schema `0.8.0`): 17 nodes · 32 edges. ## Specs diff --git a/test/fixtures/checkout-v1/expected-graph.json b/test/fixtures/checkout-v1/expected-graph.json index 7f384840..9c1e554c 100644 --- a/test/fixtures/checkout-v1/expected-graph.json +++ b/test/fixtures/checkout-v1/expected-graph.json @@ -1,5 +1,5 @@ { - "schemaVersion": "0.7.0", + "schemaVersion": "0.8.0", "nodes": [ { "id": "api:orders.post", diff --git a/test/fixtures/extract/annotation-forms/expected-graph.json b/test/fixtures/extract/annotation-forms/expected-graph.json new file mode 100644 index 00000000..ad2284d6 --- /dev/null +++ b/test/fixtures/extract/annotation-forms/expected-graph.json @@ -0,0 +1,247 @@ +{ + "schemaVersion": "0.8.0", + "nodes": [ + { + "id": "component:platform.gateway", + "nodeType": "CodeNode", + "claim": "anchored", + "label": "the gateway", + "file": "src/constant-form.ts", + "line": 10, + "layer": "edge", + "context": "platform" + }, + { + "id": "component:platform.read-model", + "nodeType": "CodeNode", + "claim": "anchored", + "label": "the read model", + "file": "src/comment-form.ts", + "line": 1, + "layer": "application", + "context": "platform" + }, + { + "id": "impl:platform.barrel", + "nodeType": "CodeNode", + "claim": "anchored", + "file": "src/constant-form.ts", + "line": 26, + "role": "barrel" + }, + { + "id": "impl:platform.gate", + "nodeType": "CodeNode", + "claim": "anchored", + "label": "the gate", + "file": "src/constant-form.ts", + "line": 17, + "role": "decider" + }, + { + "id": "impl:platform.rebuild", + "nodeType": "CodeNode", + "claim": "anchored", + "label": "the online rebuild path", + "file": "src/comment-form.ts", + "line": 13, + "role": "service" + }, + { + "id": "oracle:platform.rebuild", + "nodeType": "Anchor", + "claim": "anchored", + "file": "src/comment-form.ts", + "line": 35 + }, + { + "id": "spec:decisions.history-posture", + "nodeType": "Primitive", + "claim": "declared", + "specKind": "decision", + "altitude": "story", + "readiness": "idea", + "title": "The history posture", + "file": "specs/history-posture.sdp.md", + "sections": { + "intent": { + "outcome": "Code that answers to the posture says so through references, never satisfies." + }, + "decision": { + "decision": "The read model is rebuilt from history, never patched in place." + } + } + }, + { + "id": "spec:platform.gate", + "nodeType": "Primitive", + "claim": "declared", + "specKind": "behavior", + "altitude": "feature", + "readiness": "idea", + "title": "platform gate", + "file": "specs/gate.sdp.md", + "sections": { + "intent": { + "outcome": "Exercise the two anchor forms over the gate path." + } + }, + "deliveryFacts": [ + "implemented", + "has-verifier" + ] + }, + { + "id": "spec:platform.history-rebuild", + "nodeType": "Primitive", + "claim": "declared", + "specKind": "behavior", + "altitude": "feature", + "readiness": "idea", + "title": "platform history-rebuild", + "file": "specs/history-rebuild.sdp.md", + "sections": { + "intent": { + "outcome": "Exercise the two anchor forms over the history-rebuild path." + } + }, + "deliveryFacts": [ + "implemented", + "has-verifier" + ] + }, + { + "id": "spec:platform.rebuild", + "nodeType": "Primitive", + "claim": "declared", + "specKind": "behavior", + "altitude": "feature", + "readiness": "idea", + "title": "platform rebuild", + "file": "specs/rebuild.sdp.md", + "sections": { + "intent": { + "outcome": "Exercise the two anchor forms over the rebuild path." + }, + "behavior": { + "exampleSpace": { + "given": [ + "a read model {events:number} events behind" + ], + "when": [ + "the rebuild runs" + ], + "then": [ + "the read model is {state:string}" + ] + } + } + }, + "deliveryFacts": [ + "implemented", + "has-verifier" + ] + }, + { + "id": "test:platform.gate", + "nodeType": "Anchor", + "claim": "anchored", + "file": "src/constant-form.ts", + "line": 31 + }, + { + "id": "test:platform.rebuild", + "nodeType": "Anchor", + "claim": "anchored", + "label": "the rebuild path is verified end to end", + "file": "src/comment-form.ts", + "line": 28 + } + ], + "edges": [ + { + "from": "component:platform.read-model", + "type": "uses", + "to": "component:platform.gateway", + "claim": "anchored" + }, + { + "from": "impl:platform.gate", + "type": "memberOf", + "to": "component:platform.gateway", + "claim": "anchored" + }, + { + "from": "impl:platform.gate", + "type": "references", + "to": "spec:decisions.history-posture", + "claim": "anchored" + }, + { + "from": "impl:platform.gate", + "type": "references", + "to": "spec:platform.rebuild", + "claim": "anchored" + }, + { + "from": "impl:platform.gate", + "type": "satisfies", + "to": "spec:platform.gate", + "claim": "anchored" + }, + { + "from": "impl:platform.rebuild", + "type": "memberOf", + "to": "component:platform.read-model", + "claim": "anchored" + }, + { + "from": "impl:platform.rebuild", + "type": "references", + "to": "spec:decisions.history-posture", + "claim": "anchored" + }, + { + "from": "impl:platform.rebuild", + "type": "satisfies", + "to": "spec:platform.history-rebuild", + "claim": "anchored" + }, + { + "from": "impl:platform.rebuild", + "type": "satisfies", + "to": "spec:platform.rebuild", + "claim": "anchored" + }, + { + "from": "impl:platform.rebuild", + "type": "uses", + "to": "impl:platform.gate", + "claim": "anchored" + }, + { + "from": "oracle:platform.rebuild", + "type": "models", + "to": "spec:platform.rebuild", + "claim": "anchored" + }, + { + "from": "test:platform.gate", + "type": "verifies", + "to": "spec:platform.gate", + "claim": "anchored" + }, + { + "from": "test:platform.rebuild", + "type": "verifies", + "to": "spec:platform.history-rebuild", + "claim": "anchored" + }, + { + "from": "test:platform.rebuild", + "type": "verifies", + "to": "spec:platform.rebuild", + "claim": "anchored" + } + ] +} diff --git a/test/fixtures/extract/annotation-forms/specs/gate.sdp.md.txt b/test/fixtures/extract/annotation-forms/specs/gate.sdp.md.txt new file mode 100644 index 00000000..8721a791 --- /dev/null +++ b/test/fixtures/extract/annotation-forms/specs/gate.sdp.md.txt @@ -0,0 +1,11 @@ +--- +id: spec:platform.gate +kind: behavior +altitude: feature +readiness: idea +relations: {} +--- +# platform gate + +## Intent +- outcome: Exercise the two anchor forms over the gate path. diff --git a/test/fixtures/extract/annotation-forms/specs/history-posture.sdp.md.txt b/test/fixtures/extract/annotation-forms/specs/history-posture.sdp.md.txt new file mode 100644 index 00000000..810517fb --- /dev/null +++ b/test/fixtures/extract/annotation-forms/specs/history-posture.sdp.md.txt @@ -0,0 +1,14 @@ +--- +id: spec:decisions.history-posture +kind: decision +altitude: story +readiness: idea +relations: {} +--- +# The history posture + +## Intent +- outcome: Code that answers to the posture says so through references, never satisfies. + +## Decision +- decision: The read model is rebuilt from history, never patched in place. diff --git a/test/fixtures/extract/annotation-forms/specs/history-rebuild.sdp.md.txt b/test/fixtures/extract/annotation-forms/specs/history-rebuild.sdp.md.txt new file mode 100644 index 00000000..36174dab --- /dev/null +++ b/test/fixtures/extract/annotation-forms/specs/history-rebuild.sdp.md.txt @@ -0,0 +1,11 @@ +--- +id: spec:platform.history-rebuild +kind: behavior +altitude: feature +readiness: idea +relations: {} +--- +# platform history-rebuild + +## Intent +- outcome: Exercise the two anchor forms over the history-rebuild path. diff --git a/test/fixtures/extract/annotation-forms/specs/rebuild.sdp.md.txt b/test/fixtures/extract/annotation-forms/specs/rebuild.sdp.md.txt new file mode 100644 index 00000000..5e4506fc --- /dev/null +++ b/test/fixtures/extract/annotation-forms/specs/rebuild.sdp.md.txt @@ -0,0 +1,18 @@ +--- +id: spec:platform.rebuild +kind: behavior +altitude: feature +readiness: idea +relations: {} +--- +# platform rebuild + +## Intent +- outcome: Exercise the two anchor forms over the rebuild path. + +## Example space +```gwt-vocabulary +Given a read model {events:number} events behind +When the rebuild runs +Then the read model is {state:string} +``` diff --git a/test/fixtures/extract/annotation-forms/src/comment-form.ts.txt b/test/fixtures/extract/annotation-forms/src/comment-form.ts.txt new file mode 100644 index 00000000..86350f26 --- /dev/null +++ b/test/fixtures/extract/annotation-forms/src/comment-form.ts.txt @@ -0,0 +1,41 @@ +/** + * The read model: the declared component, realized where it is written. The prose above the + * reserved tags is local commentary and confers nothing. + * + * @sdpAnchor component:platform.read-model + * @sdpLabel the read model + * @sdpLayer application + * @sdpContext platform + * @sdpUses component:platform.gateway + */ +export const READ_MODEL = "read-model"; + +/** + * The online rebuild path. + * + * @sdpAnchor impl:platform.rebuild + * @sdpLabel the online rebuild path + * @sdpSatisfies spec:platform.rebuild, spec:platform.history-rebuild + * @sdpReferences spec:decisions.history-posture + * @sdpComponent component:platform.read-model + * @sdpUses impl:platform.gate + * @sdpRole service + */ +export async function rebuild(): Promise { + await Promise.resolve(); +} + +/** + * @sdpAnchor test:platform.rebuild + * @sdpLabel the rebuild path is verified end to end + * @sdpVerifies spec:platform.rebuild, spec:platform.history-rebuild + */ +export function rebuildTest(): void {} + +/** + * @sdpAnchor oracle:platform.rebuild + * @sdpModels spec:platform.rebuild + */ +export function expected(): string { + return "rebuilt"; +} diff --git a/test/fixtures/extract/annotation-forms/src/constant-form.ts.txt b/test/fixtures/extract/annotation-forms/src/constant-form.ts.txt new file mode 100644 index 00000000..b082d6f8 --- /dev/null +++ b/test/fixtures/extract/annotation-forms/src/constant-form.ts.txt @@ -0,0 +1,34 @@ +import { + codeAnchor, + codeAnchorId, + componentAnchorId, + ref, + specTest, + testAnchorId, +} from "@libar-dev/software-delivery-protocol/anchors"; + +export const gatewayComponent = codeAnchor({ + id: codeAnchorId("component:platform.gateway"), + label: "the gateway", + layer: "edge", + context: "platform", +}); + +export const gate = codeAnchor({ + id: codeAnchorId("impl:platform.gate"), + label: "the gate", + satisfies: [ref("spec:platform.gate")], + references: [ref("spec:decisions.history-posture"), ref("spec:platform.rebuild")], + component: componentAnchorId("component:platform.gateway"), + role: "decider", +}); + +export const barrel = codeAnchor({ + id: codeAnchorId("impl:platform.barrel"), + role: "barrel", +}); + +export const gateTest = specTest({ + id: testAnchorId("test:platform.gate"), + verifies: ref("spec:platform.gate"), +}); diff --git a/test/fixtures/extract/annotation-refusals/specs/gate.sdp.md.txt b/test/fixtures/extract/annotation-refusals/specs/gate.sdp.md.txt new file mode 100644 index 00000000..8721a791 --- /dev/null +++ b/test/fixtures/extract/annotation-refusals/specs/gate.sdp.md.txt @@ -0,0 +1,11 @@ +--- +id: spec:platform.gate +kind: behavior +altitude: feature +readiness: idea +relations: {} +--- +# platform gate + +## Intent +- outcome: Exercise the two anchor forms over the gate path. diff --git a/test/fixtures/extract/annotation-refusals/specs/history-posture.sdp.md.txt b/test/fixtures/extract/annotation-refusals/specs/history-posture.sdp.md.txt new file mode 100644 index 00000000..810517fb --- /dev/null +++ b/test/fixtures/extract/annotation-refusals/specs/history-posture.sdp.md.txt @@ -0,0 +1,14 @@ +--- +id: spec:decisions.history-posture +kind: decision +altitude: story +readiness: idea +relations: {} +--- +# The history posture + +## Intent +- outcome: Code that answers to the posture says so through references, never satisfies. + +## Decision +- decision: The read model is rebuilt from history, never patched in place. diff --git a/test/fixtures/extract/annotation-refusals/specs/history-rebuild.sdp.md.txt b/test/fixtures/extract/annotation-refusals/specs/history-rebuild.sdp.md.txt new file mode 100644 index 00000000..36174dab --- /dev/null +++ b/test/fixtures/extract/annotation-refusals/specs/history-rebuild.sdp.md.txt @@ -0,0 +1,11 @@ +--- +id: spec:platform.history-rebuild +kind: behavior +altitude: feature +readiness: idea +relations: {} +--- +# platform history-rebuild + +## Intent +- outcome: Exercise the two anchor forms over the history-rebuild path. diff --git a/test/fixtures/extract/annotation-refusals/specs/rebuild.sdp.md.txt b/test/fixtures/extract/annotation-refusals/specs/rebuild.sdp.md.txt new file mode 100644 index 00000000..5e4506fc --- /dev/null +++ b/test/fixtures/extract/annotation-refusals/specs/rebuild.sdp.md.txt @@ -0,0 +1,18 @@ +--- +id: spec:platform.rebuild +kind: behavior +altitude: feature +readiness: idea +relations: {} +--- +# platform rebuild + +## Intent +- outcome: Exercise the two anchor forms over the rebuild path. + +## Example space +```gwt-vocabulary +Given a read model {events:number} events behind +When the rebuild runs +Then the read model is {state:string} +``` diff --git a/test/fixtures/extract/annotation-refusals/src/comment-refusals.ts.txt b/test/fixtures/extract/annotation-refusals/src/comment-refusals.ts.txt new file mode 100644 index 00000000..6b9f6f87 --- /dev/null +++ b/test/fixtures/extract/annotation-refusals/src/comment-refusals.ts.txt @@ -0,0 +1,96 @@ +/** + * @sdpAnchor impl:refused.unknown-tag + * @sdpStatus implemented + */ +export const unknownTag = 1; + +/** + * @sdpReferences spec:platform.gate + */ +export const noOpener = 2; + +/** + * @sdpAnchor impl:refused.repeated + * @sdpRole service + * @sdpRole decider + */ +export const repeated = 3; + +/** + * @sdpAnchor test:refused.wrong-target + * @sdpSatisfies spec:platform.gate + */ +export const wrongTargetOnTest = 4; + +/** + * @sdpAnchor impl:refused.wrong-target + * @sdpVerifies spec:platform.gate + */ +export const wrongTargetOnCode = 5; + +/** + * @sdpAnchor impl:refused.Bad Id + */ +export const badId = 6; + +/** + * @sdpAnchor spec:refused.not-an-anchor + */ +export const specNamespace = 7; + +/** + * @sdpAnchor component:refused.layer + * @sdpLayer presentation + */ +export const openLayer = 8; + +/** + * @sdpAnchor impl:refused.layer-on-impl + * @sdpLayer domain + * @sdpContext platform + */ +export const layerOnImpl = 9; + +/** + * @sdpAnchor impl:refused.continuation + * @sdpSatisfies spec:platform.gate, + * spec:platform.rebuild + */ +export const continuation = 10; + +/** + * @sdpAnchor impl:refused.empty-item + * @sdpUses impl:platform.gate,, impl:platform.barrel + */ +export const emptyItem = 11; + +/** + * @sdpAnchor oracle:refused.plural + * @sdpModels spec:platform.gate, spec:platform.rebuild + */ +export const pluralModels = 12; + +/** + * @sdpAnchor impl:refused.role + * @sdpRole Service Layer + */ +export const roleProse = 13; + +/** + * @sdpAnchor test:refused.unbound + * @sdpLabel a verifier with nothing to verify + */ +export const unbound = 14; + +/** + * @sdpAnchor impl:refused.overlap + * @sdpSatisfies spec:platform.gate + * @sdpReferences spec:platform.gate + */ +export const overlap = 15; + +export function nested(): void { + /** @sdpAnchor impl:refused.nested */ + const inner = 16; + void inner; +} diff --git a/test/fixtures/extract/annotation-refusals/src/constant-refusals.ts.txt b/test/fixtures/extract/annotation-refusals/src/constant-refusals.ts.txt new file mode 100644 index 00000000..026244ab --- /dev/null +++ b/test/fixtures/extract/annotation-refusals/src/constant-refusals.ts.txt @@ -0,0 +1,36 @@ +import { codeAnchor, codeAnchorId, ref, specTest, testAnchorId } from "@libar-dev/software-delivery-protocol/anchors"; + +export const emptySatisfies = codeAnchor({ + id: codeAnchorId("impl:refused.empty-satisfies"), + satisfies: [], +}); + +export const repeatedSatisfies = codeAnchor({ + id: codeAnchorId("impl:refused.repeated-satisfies"), + satisfies: [ref("spec:platform.gate"), ref("spec:platform.gate")], +}); + +export const openLayer = codeAnchor({ + id: codeAnchorId("component:refused.layer"), + layer: "presentation", +}); + +export const layerOnImpl = codeAnchor({ + id: codeAnchorId("impl:refused.layer-on-impl"), + context: "platform", +}); + +export const roleProse = codeAnchor({ + id: codeAnchorId("impl:refused.role"), + role: "Service Layer", +}); + +export const nonStaticRole = codeAnchor({ + id: codeAnchorId("impl:refused.non-static-role"), + role: ["service"].join(""), +}); + +export const emptyVerifies = specTest({ + id: testAnchorId("test:refused.empty-verifies"), + verifies: [], +}); 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 index a1d63f15..864ac7a5 100644 --- a/test/fixtures/extract/invalid-missing-envelope-fields/bare-binding.ts.txt +++ b/test/fixtures/extract/invalid-missing-envelope-fields/bare-binding.ts.txt @@ -1,3 +1,7 @@ -import { codeAnchor } from "@libar-dev/software-delivery-protocol"; +import { codeAnchor, specTest } from "@libar-dev/software-delivery-protocol"; +// A code anchor needs only its id (the binding grain: satisfies is optional); a test anchor +// still needs its verifies target. export const bareBinding = codeAnchor({}); + +export const bareVerifier = specTest({}); diff --git a/test/gherkin-view.test.ts b/test/gherkin-view.test.ts index b62095bf..6a0bd23f 100644 --- a/test/gherkin-view.test.ts +++ b/test/gherkin-view.test.ts @@ -59,6 +59,7 @@ function specContext(overrides: Partial = {}): SpecContext { relationsOut: [], relationsIn: [], implementations: [], + references: [], verifiers: [], findings: [], ...overrides, diff --git a/test/graph-schema.test.ts b/test/graph-schema.test.ts index 40196811..b1dc9891 100644 --- a/test/graph-schema.test.ts +++ b/test/graph-schema.test.ts @@ -30,7 +30,7 @@ const openSectionOrderTestAnchor = specTest({ void openSectionOrderTestAnchor; describe("graph schema", () => { - it("serializes authored entry order with description first and schema 0.7.0", () => { + it("serializes authored entry order with description first and schema 0.8.0", () => { const graph = deriveFixtureGraph({ specs: [ spec({ @@ -53,7 +53,7 @@ describe("graph schema", () => { }); const serialized = JSON.parse(serializeGraph(graph)) as GraphSchema; const node = serialized.nodes.find((entry) => entry.nodeType === "Primitive"); - expect(serialized.schemaVersion).toBe("0.7.0"); + expect(serialized.schemaVersion).toBe("0.8.0"); expect(Object.keys(node?.sections?.design ?? {})).toEqual([ "description", "zeta", @@ -197,10 +197,17 @@ The Model prose. }); it("exports the graph schema contracts", () => { - expect(schemaVersion).toBe("0.7.0"); + expect(schemaVersion).toBe("0.8.0"); expect(graphNodeTypes).toEqual(["Primitive", "Pack", "Anchor", "CodeNode"]); expect(deliveryFactNames).toEqual(["implemented", "has-verifier", "observed"]); - expect(derivedEdgeTypes).toEqual(["belongsTo", "satisfies", "models", "memberOf", "uses"]); + expect(derivedEdgeTypes).toEqual([ + "belongsTo", + "satisfies", + "models", + "memberOf", + "uses", + "references", + ]); expect(graphEdgeTypes).toEqual([ "refines", "dependsOn", @@ -213,6 +220,7 @@ The Model prose. "models", "memberOf", "uses", + "references", ]); }); }); diff --git a/test/helpers/fixture-graph.ts b/test/helpers/fixture-graph.ts index 62e1ae96..21f5315e 100644 --- a/test/helpers/fixture-graph.ts +++ b/test/helpers/fixture-graph.ts @@ -34,7 +34,11 @@ export function deriveFixtureGraph(model: FixtureModel): GraphSchema { const anchors: ReifiedAnchor[] = (model.anchors ?? []).map((entry, position) => ({ data: entry as unknown as Record, id: entry.id, - flavor: "satisfies" in entry ? "code" : "models" in entry ? "oracle" : "test", + flavor: entry.id.startsWith("test:") + ? "test" + : entry.id.startsWith("oracle:") + ? "oracle" + : "code", file: "src/fixture.ts", line: position + 1, })); diff --git a/test/helpers/map-sets.ts b/test/helpers/map-sets.ts new file mode 100644 index 00000000..ff33abd1 --- /dev/null +++ b/test/helpers/map-sets.ts @@ -0,0 +1,35 @@ +/** + * Runs `work` and counts the `Map.prototype.set` calls it makes. A grouping that pushes into a + * bucket sets each bucket once; one that replaces the bucket sets once per unit, so the count + * tells the two apart without a clock. The method is restored before this returns. No vitest spy + * is used: a spy whose own bookkeeping touched a Map would count itself. + */ +export function countMapSets(work: () => T): { readonly value: T; readonly sets: number } { + const descriptor = Object.getOwnPropertyDescriptor(Map.prototype, "set"); + + if (descriptor === undefined || typeof descriptor.value !== "function") { + throw new Error("Map.prototype.set is not a data property"); + } + + const original = descriptor.value as ( + this: Map, + key: unknown, + value: unknown, + ) => Map; + let sets = 0; + + Object.defineProperty(Map.prototype, "set", { + ...descriptor, + value(this: Map, key: unknown, value: unknown): Map { + sets += 1; + return original.call(this, key, value); + }, + }); + + try { + const value = work(); + return { value, sets }; + } finally { + Object.defineProperty(Map.prototype, "set", descriptor); + } +} diff --git a/test/package-smoke.test.ts b/test/package-smoke.test.ts index d0c324e5..12e5c2e6 100644 --- a/test/package-smoke.test.ts +++ b/test/package-smoke.test.ts @@ -19,10 +19,12 @@ const repositoryRoot = fileURLToPath(new URL("..", import.meta.url)); const importFixture = join(repositoryRoot, "test/fixtures/import/round-trip/behavior.sdp.ts.txt"); const expectedRootExports = [ + "CODE_ANCHOR_LAYERS", "CODE_ANCHOR_NAMESPACES", "ENTRY_ADDRESS_SECTIONS", "MAX_MERMAID_EDGES_PER_DIAGRAM", "MAX_MERMAID_NODES_PER_DIAGRAM", + "PROTOCOL_ANCHORS_MODULE_SPECIFIER", "PROTOCOL_MODULE_SPECIFIER", "SPEC_ALTITUDES", "SPEC_KINDS", diff --git a/test/reader.test.ts b/test/reader.test.ts index 52763164..7fb35748 100644 --- a/test/reader.test.ts +++ b/test/reader.test.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { afterAll, describe, expect, it } from "vitest"; import { codeAnchor, @@ -23,6 +23,7 @@ import { verifies, } from "../src/index.js"; import type { GraphSchema, Reader } from "../src/index.js"; +import { materializeExtractCorpus, removeMaterializedCorpus } from "./helpers/extract-corpus.js"; import { deriveFixtureGraph } from "./helpers/fixture-graph.js"; const exampleRoot = join(fileURLToPath(new URL("..", import.meta.url)), "examples", "checkout-v1"); @@ -34,6 +35,55 @@ function exampleReader(): Reader { return createReader(exampleGraph); } +/** Both anchor forms with plural targets, `references`, roles, and two components carrying a + * layer and a context: the binding grain over a real extraction. */ +const annotationRoot = materializeExtractCorpus("annotation-forms"); +const annotationGraph = extract({ root: annotationRoot }).graph; + +afterAll(() => { + removeMaterializedCorpus(annotationRoot); +}); + +function annotationReader(): Reader { + return createReader(annotationGraph); +} + +/** One file holding only an identity-only unit, beside one bound unit. */ +function identityOnlyGraph(): GraphSchema { + return { + schemaVersion, + nodes: [ + { + id: "spec:probe.bound", + nodeType: "Primitive", + claim: "declared", + specKind: "behavior", + altitude: "feature", + readiness: "idea", + file: "specs/bound.sdp.md", + }, + { + id: "impl:probe.barrel", + nodeType: "CodeNode", + claim: "anchored", + file: "src/barrel.ts", + line: 3, + role: "barrel", + }, + { + id: "impl:probe.bound", + nodeType: "CodeNode", + claim: "anchored", + file: "src/bound.ts", + line: 5, + }, + ], + edges: [ + { from: "impl:probe.bound", type: "satisfies", to: "spec:probe.bound", claim: "anchored" }, + ], + }; +} + describe("the reader — the thin typed loader behind the agent surface", () => { describe("flat accessors", () => { it("exposes authored open-section and model-term order in a Spec context", () => { @@ -1114,6 +1164,151 @@ describe("the reader — the thin typed loader behind the agent surface", () => }); }); + describe("the binding grain: references and unlinked code units", () => { + it("lists the units that reference a Spec beside its implementations, with role and location", () => { + const context = annotationReader().specContext("spec:platform.rebuild"); + + expect(context?.implementations).toEqual([ + { + codeId: "impl:platform.rebuild", + claim: "anchored", + label: "the online rebuild path", + file: "src/comment-form.ts", + line: 13, + role: "service", + }, + ]); + expect(context?.references).toEqual([ + { + codeId: "impl:platform.gate", + claim: "anchored", + label: "the gate", + file: "src/constant-form.ts", + line: 17, + role: "decider", + }, + ]); + }); + + it("never confers a delivery fact through references", () => { + const context = annotationReader().specContext("spec:decisions.history-posture"); + + expect(context?.references.map((binding) => binding.codeId)).toEqual([ + "impl:platform.gate", + "impl:platform.rebuild", + ]); + expect(context?.implementations).toEqual([]); + expect(context?.deliveryFacts).toEqual([]); + }); + + it("carries layer and context on a component's binding row", () => { + const graph = deriveFixtureGraph({ + specs: [ + spec({ + id: specId("spec:orders.order-management"), + title: "Order management", + kind: "behavior", + altitude: "feature", + readiness: "idea", + intent: { outcome: "Manage orders." }, + }), + ], + anchors: [ + codeAnchor({ + id: codeAnchorId("component:orders.core"), + label: "the orders core", + satisfies: specId("spec:orders.order-management"), + layer: "domain", + context: "orders", + }), + ], + }); + + expect( + createReader(graph).specContext("spec:orders.order-management")?.implementations, + ).toEqual([ + { + codeId: "component:orders.core", + claim: "anchored", + label: "the orders core", + file: "src/fixture.ts", + line: 1, + layer: "domain", + context: "orders", + }, + ]); + }); + + it("reaches a referenced Spec from a source file", () => { + expect(annotationReader().byFile("src/constant-form.ts")).toEqual({ + path: "src/constant-form.ts", + nodes: [ + { id: "component:platform.gateway", nodeType: "CodeNode", line: 10 }, + { id: "impl:platform.barrel", nodeType: "CodeNode", line: 26 }, + { id: "impl:platform.gate", nodeType: "CodeNode", line: 17 }, + { id: "test:platform.gate", nodeType: "Anchor", line: 31 }, + ], + specs: ["spec:decisions.history-posture", "spec:platform.gate", "spec:platform.rebuild"], + }); + }); + + it("returns an identity-only unit with an empty Spec list", () => { + expect(createReader(identityOnlyGraph()).byFile("src/barrel.ts")).toEqual({ + path: "src/barrel.ts", + nodes: [{ id: "impl:probe.barrel", nodeType: "CodeNode", line: 3 }], + specs: [], + }); + }); + + it("traverses references as a binding, naming the edge type and its claim", () => { + const radius = annotationReader().blastRadius(["src/comment-form.ts"]); + const posture = radius.impactedSpecs.find( + (item) => item.id === "spec:decisions.history-posture", + ); + + expect(posture?.reasons).toEqual([ + { + file: "src/comment-form.ts", + throughBinding: { + id: "impl:platform.rebuild", + edgeType: "references", + claim: "anchored", + }, + }, + ]); + }); + + it("lists changed units with no Spec linkage as unlinked, never coverage-unknown or dropped", () => { + const radius = annotationReader().blastRadius([ + "src/constant-form.ts", + "src/comment-form.ts", + "src/unrecorded.ts", + ]); + + expect(radius.unlinked).toEqual([ + { file: "src/comment-form.ts", id: "component:platform.read-model" }, + { file: "src/constant-form.ts", id: "component:platform.gateway" }, + { file: "src/constant-form.ts", id: "impl:platform.barrel" }, + ]); + expect(radius.coverageUnknown).toEqual(["src/unrecorded.ts"]); + }); + + it("keeps a file holding only an identity-only unit out of coverage-unknown and out of impact", () => { + const radius = createReader(identityOnlyGraph()).blastRadius(["src/barrel.ts"]); + + expect(radius.unlinked).toEqual([{ file: "src/barrel.ts", id: "impl:probe.barrel" }]); + expect(radius.coverageUnknown).toEqual([]); + expect(radius.impactedSpecs).toEqual([]); + expect(radius.atRisk).toEqual([]); + }); + + it("reports nothing unlinked when every changed unit has a Spec linkage", () => { + expect(exampleReader().blastRadius(["src/orders/create-order.use-case.ts"]).unlinked).toEqual( + [], + ); + }); + }); + describe("purity and determinism", () => { it("never mutates the graph and answers identically across fresh readers", () => { const before = JSON.stringify(exampleGraph); diff --git a/test/recipes.test.ts b/test/recipes.test.ts index 64baf733..c0a19ed1 100644 --- a/test/recipes.test.ts +++ b/test/recipes.test.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { compileBody } from "../src/cli/q-command.js"; import { runSdpCli } from "../src/cli/sdp.js"; import { createReader, @@ -13,9 +14,10 @@ import { schemaVersion, validateGraph, } from "../src/index.js"; -import type { ExtractionResult, GraphEdge, GraphNode } from "../src/index.js"; +import type { ExtractionResult, GraphEdge, GraphNode, GraphSchema } from "../src/index.js"; import { expectedComponentIds, expectedUsesEdges } from "./self-hosting-oracle/structural-edges.js"; import { createCaptureOutput } from "./helpers/cli-capture.js"; +import { countMapSets } from "./helpers/map-sets.js"; // The recipe corpus is executable documentation: every fenced `js` body in the catalog must run // verbatim through the real front door, or the catalog is lying about what an agent can paste. @@ -411,6 +413,8 @@ describe("the agent-surface recipe corpus", () => { "twenty-four", "twenty-five", "twenty-six", + "twenty-seven", + "twenty-eight", ] as const; const countWord = countWords[recipes.length]; const lastOrdinal = recipes[recipes.length - 1]?.ordinal; @@ -481,11 +485,13 @@ describe("the agent-surface recipe corpus", () => { "pinned declarations", "address resolution", "dependency cycles", + "references into a design", + "roles, layers and contexts", ]) { expect(agentSurfaceProse).toContain(phrase); } - for (const ordinal of [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]) { + for (const ordinal of [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28]) { expect(onRamps.sessions).toContain(`recipe ${String(ordinal)}`); } }); @@ -667,6 +673,7 @@ describe("the agent-surface recipe corpus", () => { "atRiskSpecs", "atRiskOther", "coverageUnknownFiles", + "unlinkedUnits", ]), ); expect(changedFiles).toEqual(radius.changedFiles); @@ -747,6 +754,34 @@ describe("the agent-surface recipe corpus", () => { } expect(asArray(result.coverageUnknownFiles)).toEqual(radius.coverageUnknown); + expect(asArray(result.unlinkedUnits)).toEqual( + radius.unlinked.map((unit) => ({ id: unit.id, file: unit.file })), + ); + }); + + it("names a changed identity-only unit as unlinked, never coverage-unknown or dropped", async () => { + const file = "src/probe/identity-only.ts"; + const unit: GraphNode = { + id: "impl:probe.identity-only", + nodeType: "CodeNode", + claim: "anchored", + file, + line: 1, + }; + const extraction: ExtractionResult = { + counts: derived.counts, + report: derived.report, + graph: { + schemaVersion: derived.graph.schemaVersion, + nodes: [...derived.graph.nodes, unit], + edges: derived.graph.edges, + }, + }; + const result = asRecord(await runRecipe(recipeByOrdinal(4), [file], extraction)); + + expect(result.unlinkedUnits).toEqual([{ id: unit.id, file }]); + expect(result.coverageUnknownFiles).toEqual([]); + expect(result.impactedSpecs).toEqual([]); }); it("returns a pack's review backbone with its verifier gaps", async () => { @@ -1909,7 +1944,7 @@ export const probe = spec({ } describe("register recipe semantics", () => { - it.each([20, 21, 22, 23, 24, 25, 26])( + it.each([20, 21, 22, 23, 24, 25, 26, 27, 28])( "runs recipe %s as written in default output", async (ordinal) => { const capture = createCaptureOutput(); @@ -2135,7 +2170,7 @@ describe("register recipe semantics", () => { check(entry, insideTotals || key === "totals"); } }; - for (const ordinal of [20, 21, 22, 23, 24, 25, 26]) { + for (const ordinal of [20, 21, 22, 23, 24, 25, 26, 27, 28]) { const output = asRecord(await runRecipe(recipeByOrdinal(ordinal))); check(output); for (const key of [ @@ -2149,6 +2184,9 @@ describe("register recipe semantics", () => { "matches", "rows", "cycles", + "roles", + "layers", + "contexts", ]) { if (key in output) expect(Array.isArray(output[key])).toBe(true); } @@ -3556,3 +3594,371 @@ relations: {} ]); }); }); + +const studioPackId = "pack:spec-studio-v1"; + +/** Plain code-unit order, the order the reader sorts relations and units in. */ +function codeUnitOrder(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +/** One recipe 27 row, computed from the raw graph rather than through the reader. */ +function referenceRowFromGraph(extraction: ExtractionResult, memberId: string) { + const nodes = new Map(extraction.graph.nodes.map((node) => [node.id, node] as const)); + const member = nodes.get(memberId); + const unitsBy = (type: "references" | "satisfies", specId: string) => + extraction.graph.edges + .filter((edge) => edge.type === type && edge.to === specId) + .filter((edge) => nodes.get(edge.from)?.nodeType === "CodeNode") + .map((edge) => edge.from) + .sort(); + const located = (ids: readonly string[]) => + ids.map((id) => ({ id, file: nodes.get(id)?.file ?? null })); + const factsOf = (id: string) => { + const node = nodes.get(id); + return node?.nodeType === "Primitive" ? (node.deliveryFacts ?? []) : []; + }; + // Builds on: the member's own declared `dependsOn` and `refines` edges whose target carries + // `implemented`, in relation then id order, each with the units that realize the target. + const buildsOn = extraction.graph.edges + .filter((edge) => edge.from === memberId && edge.claim === "declared") + .filter((edge) => edge.type === "dependsOn" || edge.type === "refines") + .filter((edge) => factsOf(edge.to).includes("implemented")) + .sort((left, right) => codeUnitOrder(left.type, right.type) || codeUnitOrder(left.to, right.to)) + .map((edge) => ({ + id: edge.to, + via: edge.type, + implementedBy: located(unitsBy("satisfies", edge.to)), + })); + + return { + id: memberId, + resolved: member?.nodeType === "Primitive", + referencedBy: located(unitsBy("references", memberId)), + implementedBy: unitsBy("satisfies", memberId), + hasVerifier: factsOf(memberId).includes("has-verifier"), + buildsOn, + }; +} + +/** Every value one CodeNode field carries, with its units, computed from the raw graph. */ +function taxonomyFromGraph(extraction: ExtractionResult, field: "role" | "layer" | "context") { + const byValue = new Map(); + for (const node of extraction.graph.nodes) { + const value = node.nodeType === "CodeNode" ? node[field] : undefined; + if (value !== undefined) byValue.set(value, [...(byValue.get(value) ?? []), node.id]); + } + return [...byValue.keys()] + .sort() + .map((value) => ({ value, units: [...(byValue.get(value) ?? [])].sort() })); +} + +/** + * The base graph plus one identity-only code unit that references two Studio members and carries + * a role, one component with a layer and a context, and a dangling member on the Studio Pack. + */ +function referenceProbe(): { + readonly extraction: ExtractionResult; + readonly targets: readonly [string, string]; +} { + const pack = derived.graph.nodes.find((node) => node.id === studioPackId); + if (pack?.nodeType !== "Pack") throw new Error(`the corpus must hold ${studioPackId}`); + const [first, second] = pack.members; + if (first === undefined || second === undefined) { + throw new Error(`${studioPackId} must hold at least two members`); + } + const unit: GraphNode = { + id: "impl:probe.studio-reader", + nodeType: "CodeNode", + claim: "anchored", + label: "answers to the Studio design", + file: "src/probe/studio-reader.ts", + line: 1, + role: "probe-role", + }; + const seam: GraphNode = { + id: "component:probe.studio-seam", + nodeType: "CodeNode", + claim: "anchored", + file: "src/probe/studio-seam.ts", + line: 1, + layer: "domain", + context: "probe-context", + }; + const references: readonly GraphEdge[] = [first, second].map((to) => ({ + from: unit.id, + type: "references", + to, + claim: "anchored", + })); + + return { + targets: [first, second], + extraction: { + counts: derived.counts, + report: derived.report, + graph: { + schemaVersion: derived.graph.schemaVersion, + nodes: [ + ...derived.graph.nodes.map((node) => + node === pack + ? { ...pack, members: [...pack.members, "spec:probe.absent-member"] } + : node, + ), + unit, + seam, + ], + edges: [...derived.graph.edges, ...references], + }, + }, + }; +} + +describe("design references and the architecture taxonomy", () => { + it("returns one row per Studio member with four independent facts", async () => { + const recipe = recipeByOrdinal(27); + const result = asRecord(await runRecipe(recipe)); + const pack = derived.graph.nodes.find((node) => node.id === studioPackId); + if (pack?.nodeType !== "Pack") throw new Error(`the corpus must hold ${studioPackId}`); + const rows = asArray(result.rows).map(asRecord); + + expect(result.found).toBe(true); + expect(result.id).toBe(studioPackId); + expect(rows.map((row) => stringAt(row, "id"))).toEqual(pack.members); + expect(rows).toEqual(pack.members.map((id) => referenceRowFromGraph(derived, id))); + + const count = (test: (row: Record) => boolean) => rows.filter(test).length; + const listed = (row: Record, key: string) => asArray(row[key]).length > 0; + expect(result.totals).toEqual({ + members: rows.length, + withReferences: count((row) => listed(row, "referencedBy")), + withImplementations: count((row) => listed(row, "implementedBy")), + withVerifier: count((row) => row.hasVerifier === true), + withBuildsOn: count((row) => listed(row, "buildsOn")), + unbound: count( + (row) => + !listed(row, "referencedBy") && !listed(row, "implementedBy") && row.hasVerifier !== true, + ), + }); + + const absent = await runRecipe({ + ...recipe, + body: recipe.body.replace(studioPackId, "pack:absent-v1"), + }); + expect(absent).toEqual({ id: "pack:absent-v1", found: false }); + }); + + it("lists a referencing unit beside the realizing units without conferring implemented", async () => { + const { extraction, targets } = referenceProbe(); + const [target] = targets; + const before = asArray(asRecord(await runRecipe(recipeByOrdinal(27))).rows).map(asRecord); + const after = asArray( + asRecord(await runRecipe(recipeByOrdinal(27), undefined, extraction)).rows, + ).map(asRecord); + const rowFor = (rows: readonly Record[], id: string) => + rows.find((row) => row.id === id); + + expect(asArray(rowFor(after, target)?.referencedBy)).toContainEqual({ + id: "impl:probe.studio-reader", + file: "src/probe/studio-reader.ts", + }); + expect(rowFor(after, target)?.implementedBy).toEqual(rowFor(before, target)?.implementedBy); + expect(rowFor(after, target)?.hasVerifier).toBe(rowFor(before, target)?.hasVerifier); + expect(createReader(extraction.graph).specContext(target)?.deliveryFacts).toEqual( + reader.specContext(target)?.deliveryFacts, + ); + expect(rowFor(after, "spec:probe.absent-member")).toEqual({ + id: "spec:probe.absent-member", + resolved: false, + referencedBy: [], + implementedBy: [], + hasVerifier: false, + buildsOn: [], + }); + }); + + it("lists the reader beside each Studio member that depends on it, with its realizing units", async () => { + const rows = asArray(asRecord(await runRecipe(recipeByOrdinal(27))).rows).map(asRecord); + const readerId = "spec:consumers.reader"; + const readerUnits = derived.graph.edges + .filter((edge) => edge.type === "satisfies" && edge.to === readerId) + .map((edge) => edge.from) + .sort() + .map((id) => ({ + id, + file: derived.graph.nodes.find((node) => node.id === id)?.file ?? null, + })); + const dependents = derived.graph.edges + .filter((edge) => edge.type === "dependsOn" && edge.to === readerId) + .map((edge) => edge.from) + .filter((id) => rows.some((row) => row.id === id)) + .sort(); + + // The design that builds on the reader says so on its own side; the reader's code names + // none of its consumers' designs. + expect(reader.specContext(readerId)?.deliveryFacts).toContain("implemented"); + expect(readerUnits.map((unit) => unit.file)).toContain("src/reader/reader.ts"); + expect(dependents).toEqual(["spec:consumers.spec-studio", "spec:consumers.spec-studio.data"]); + for (const id of dependents) { + expect(asArray(rows.find((row) => row.id === id)?.buildsOn), id).toContainEqual({ + id: readerId, + via: "dependsOn", + implementedBy: readerUnits, + }); + } + expect( + derived.graph.edges.filter( + (edge) => edge.type === "references" && dependents.includes(edge.to), + ), + ).toEqual([]); + // A target with no `implemented` fact stays out of the column: the Studio depends on the + // edit model, which no unit realizes. + expect(reader.specContext("spec:consumers.edit-model")?.deliveryFacts).not.toContain( + "implemented", + ); + const studio = asArray(rows.find((row) => row.id === "spec:consumers.spec-studio")?.buildsOn); + expect(studio.map((entry) => asRecord(entry).id)).not.toContain("spec:consumers.edit-model"); + }); + + it("derives builds-on from the member's relations without conferring a fact on it", async () => { + const pack = derived.graph.nodes.find((node) => node.id === studioPackId); + if (pack?.nodeType !== "Pack") throw new Error(`the corpus must hold ${studioPackId}`); + const [member] = pack.members; + if (member === undefined) throw new Error(`${studioPackId} must hold a member`); + const primitives = derived.graph.nodes.filter((node) => node.nodeType === "Primitive"); + const related = new Set( + derived.graph.edges.filter((edge) => edge.from === member).map((edge) => edge.to), + ); + const built = primitives.find( + (node) => + node.id !== member && + !related.has(node.id) && + (node.deliveryFacts ?? []).includes("implemented"), + ); + const unbuilt = primitives.find( + (node) => + node.id !== member && + !related.has(node.id) && + !(node.deliveryFacts ?? []).includes("implemented"), + ); + if (built === undefined || unbuilt === undefined) { + throw new Error("the corpus must hold an implemented and an unimplemented Spec"); + } + const extraction: ExtractionResult = { + counts: derived.counts, + report: derived.report, + graph: { + schemaVersion: derived.graph.schemaVersion, + nodes: derived.graph.nodes, + edges: [ + ...derived.graph.edges, + { from: member, type: "dependsOn", to: built.id, claim: "declared" }, + { from: member, type: "refines", to: unbuilt.id, claim: "declared" }, + ], + }, + }; + const rowFor = async (probe: ExtractionResult) => + asArray(asRecord(await runRecipe(recipeByOrdinal(27), undefined, probe)).rows) + .map(asRecord) + .find((row) => row.id === member); + const before = await rowFor(derived); + const after = await rowFor(extraction); + + expect(after).toEqual(referenceRowFromGraph(extraction, member)); + expect(asArray(after?.buildsOn)).toContainEqual({ + id: built.id, + via: "dependsOn", + implementedBy: referenceRowFromGraph(extraction, built.id).implementedBy.map((id) => ({ + id, + file: derived.graph.nodes.find((node) => node.id === id)?.file ?? null, + })), + }); + expect(asArray(after?.buildsOn).map((entry) => asRecord(entry).id)).not.toContain(unbuilt.id); + expect(after?.implementedBy).toEqual(before?.implementedBy); + expect(after?.hasVerifier).toBe(before?.hasVerifier); + expect(createReader(extraction.graph).specContext(member)?.deliveryFacts).toEqual( + reader.specContext(member)?.deliveryFacts, + ); + }); + + it("returns the role, layer, and context taxonomy from the code units alone", async () => { + const result = asRecord(await runRecipe(recipeByOrdinal(28))); + const units = derived.graph.nodes.filter((node) => node.nodeType === "CodeNode"); + const references = derived.graph.edges.filter((edge) => edge.type === "references"); + + expect(result).toEqual({ + totals: { + codeUnits: units.length, + withRole: units.filter((unit) => unit.role !== undefined).length, + withLayer: units.filter((unit) => unit.layer !== undefined).length, + withContext: units.filter((unit) => unit.context !== undefined).length, + references: references.length, + referencingUnits: new Set(references.map((edge) => edge.from)).size, + referencedSpecs: new Set(references.map((edge) => edge.to)).size, + }, + roles: taxonomyFromGraph(derived, "role"), + layers: taxonomyFromGraph(derived, "layer"), + contexts: taxonomyFromGraph(derived, "context"), + }); + }); + + it("counts references as edges apart from the units that carry them", async () => { + const { extraction } = referenceProbe(); + const before = asRecord(asRecord(await runRecipe(recipeByOrdinal(28))).totals); + const result = asRecord(await runRecipe(recipeByOrdinal(28), undefined, extraction)); + const after = asRecord(result.totals); + const valueRow = (key: string, value: string) => + asArray(result[key]) + .map(asRecord) + .find((row) => row.value === value); + + expect(numberAt(after, "codeUnits")).toBe(numberAt(before, "codeUnits") + 2); + expect(numberAt(after, "references")).toBe(numberAt(before, "references") + 2); + expect(numberAt(after, "referencingUnits")).toBe(numberAt(before, "referencingUnits") + 1); + expect(valueRow("roles", "probe-role")).toEqual({ + value: "probe-role", + units: ["impl:probe.studio-reader"], + }); + expect(valueRow("contexts", "probe-context")).toEqual({ + value: "probe-context", + units: ["component:probe.studio-seam"], + }); + expect(asArray(valueRow("layers", "domain")?.units)).toContain("component:probe.studio-seam"); + }); + + // The sink compiles a body this way and calls it with the same three bindings; calling the + // compiled body directly keeps the count to the body alone. + it("creates each taxonomy bucket once, however many units share the value", async () => { + const ids = Array.from( + { length: 20_000 }, + (_unit, index) => `component:probe.unit-${String(index).padStart(5, "0")}`, + ); + const graph: GraphSchema = { + schemaVersion, + // Reverse insertion: each row lists its units in id order, never the graph's. + nodes: [...ids].reverse().map( + (id): GraphNode => ({ + id, + nodeType: "CodeNode", + claim: "anchored", + file: "src/probe.ts", + role: "service", + context: "probe", + }), + ), + edges: [], + }; + const body = compileBody(recipeByOrdinal(28).body); + const reader = createReader(graph); + const report = validateGraph(graph); + const { value, sets } = countMapSets(() => body(reader, graph, report)); + const result = asRecord(await value); + + // Two buckets, `service` and `probe`, two Map sets. Replacing the bucket on every insertion + // would set once per unit and attribute, 40,000 here. + expect(sets).toBe(2); + expect(result.roles).toEqual([{ value: "service", units: ids }]); + expect(result.contexts).toEqual([{ value: "probe", units: ids }]); + expect(result.layers).toEqual([]); + }); +}); diff --git a/test/self-hosting-graph.test.ts b/test/self-hosting-graph.test.ts index ab032214..b9bac9cb 100644 --- a/test/self-hosting-graph.test.ts +++ b/test/self-hosting-graph.test.ts @@ -22,11 +22,13 @@ import { ref, specTest, testAnchorId } from "@libar-dev/software-delivery-protoc import { extract, validateGraph } from "../src/index.js"; import { auditStructuralCoverage } from "./helpers/structural-coverage.js"; -import type { ExpectedSpec } from "./self-hosting-oracle/index.js"; +import type { ExpectedAnchor, ExpectedSpec } from "./self-hosting-oracle/index.js"; import { acceptedArchitecturalUnits, coarseGrainCoverage, expectedAnchors, + expectedBindingEdges, + expectedCommentAnchorIds, expectedComponentIds, expectedDeclaredRelations, expectedMemberOfEdges, @@ -158,6 +160,21 @@ function lineContaining(source: string, token: string): number { return line + 1; } +function commentAnchorLine(source: string, id: string): number { + const blocks = [...source.matchAll(/\/\*\*[\s\S]*?\*\//gu)].filter((match) => + match[0].split("\n").some((line) => line.trim() === `* @sdpAnchor ${id}`), + ); + expect(blocks, id).toHaveLength(1); + const block = blocks[0]; + return block === undefined ? 0 : source.slice(0, block.index).split("\n").length; +} + +function anchorDeclarationLine(source: string, anchor: ExpectedAnchor): number { + return anchor.constant === undefined + ? commentAnchorLine(source, anchor.id) + : lineContaining(source, `const ${anchor.constant}`); +} + function compareCoverageMismatch( left: { unit: string; anchorId: string; target: string }, right: { unit: string; anchorId: string; target: string }, @@ -175,7 +192,7 @@ describe("the self-hosting corpus", () => { expect(result.report.findings).toEqual([]); }); - it("reports exactly the five informative honesty gaps and the eight unbacked prose mentions", () => { + it("reports exactly the five informative honesty gaps and the nine unbacked prose mentions", () => { expect( validateGraph(result.graph).findings.map( ({ validatorId, family, severity, subjectId, relatedId }) => ({ @@ -193,12 +210,20 @@ describe("the self-hosting corpus", () => { // The literals are the corpus checkpoint. The authored arrays are measured against the same // literals rather than standing in for them, so a transcription slip in an oracle module // cannot certify itself by moving both sides of a comparison at once. - expect(result.counts).toEqual({ specs: 225, packs: 2, anchors: 233 }); - expect(expectedSpecs).toHaveLength(225); + // re-measured under plan 40 + expect(result.counts).toEqual({ specs: 228, packs: 2, anchors: 235 }); + // re-measured under plan 40 + expect(expectedSpecs).toHaveLength(228); + // re-measured under plan 40 expect(expectedPackMembers).toHaveLength(225); - expect(expectedAnchors).toHaveLength(233); - expect(result.graph.nodes).toHaveLength(460); - expect(result.graph.edges).toHaveLength(1055); + // re-measured under plan 40 + expect(expectedAnchors).toHaveLength(235); + // re-measured under plan 40 + expect(expectedBindingEdges).toHaveLength(241); + // re-measured under plan 40 + expect(result.graph.nodes).toHaveLength(465); + // re-measured under plan 40 + expect(result.graph.edges).toHaveLength(1076); }); it("rosters exactly the authored Spec, Pack, and anchor node ids", () => { @@ -240,6 +265,7 @@ describe("the self-hosting corpus", () => { }); it("holds the frozen stated-readiness distribution", () => { + // re-measured under plan 40 expect( primitiveNodes.reduce>( (histogram, node) => ({ @@ -248,7 +274,7 @@ describe("the self-hosting corpus", () => { }), {}, ), - ).toEqual({ defined: 58, idea: 4, ready: 150, scoped: 13 }); + ).toEqual({ defined: 58, idea: 4, ready: 153, scoped: 13 }); }); it("derives the Pack membership edges from the manifest, in manifest order", () => { @@ -266,7 +292,7 @@ describe("the self-hosting corpus", () => { expect(packNodes).toEqual(expectedPacks); }); - it("derives one binding edge per authored anchor", () => { + it("derives the independent zero-to-many binding edge roster", () => { expect( result.graph.edges .filter( @@ -274,7 +300,7 @@ describe("the self-hosting corpus", () => { ) .map((edge) => [edge.from, edge.type, edge.to]) .sort(), - ).toEqual(expectedAnchors.map((anchor) => [anchor.id, anchor.type, anchor.target]).sort()); + ).toEqual([...expectedBindingEdges].sort()); }); it("rosters exactly the accepted component set", () => { @@ -287,6 +313,8 @@ describe("the self-hosting corpus", () => { }); it("gives every owned impl/api CodeNode exactly one component", () => { + // re-measured under plan 40 + expect(expectedMemberOfEdges).toHaveLength(87); const exceptions = new Set(structuralMembershipExceptions); const codeUnits = result.graph.nodes.filter( (node) => @@ -310,6 +338,8 @@ describe("the self-hosting corpus", () => { }); it("derives exactly the sparse authored component uses edges", () => { + // re-measured under plan 40 + expect(expectedUsesEdges).toHaveLength(39); expect( result.graph.edges .filter((edge) => edge.type === "uses") @@ -442,6 +472,31 @@ describe("the self-hosting corpus", () => { expect(mismatches, message).toEqual([]); }); + it("finds every converted comment anchor at its block's first line", () => { + // re-measured under plan 40 + expect(expectedCommentAnchorIds).toHaveLength(17); + expect( + expectedAnchors + .filter((anchor) => anchor.constant === undefined) + .map((anchor) => anchor.id) + .sort(), + ).toEqual([...expectedCommentAnchorIds].sort()); + for (const id of expectedCommentAnchorIds) { + const anchor = expectedAnchors.find((entry) => entry.id === id); + expect(anchor, id).toBeDefined(); + if (anchor === undefined) continue; + const source = readFileSync(join(repoRoot, anchor.file), "utf8"); + expect( + result.graph.nodes.find((node) => node.id === id), + id, + ).toMatchObject({ + file: anchor.file, + line: commentAnchorLine(source, id), + claim: "anchored", + }); + } + }); + it("projects every anchor and code node at the line its declaration occupies", () => { const expectedAnchorNodes = expectedAnchors .map((anchor) => { @@ -453,7 +508,10 @@ describe("the self-hosting corpus", () => { claim: "anchored", label: anchor.label, file: anchor.file, - line: lineContaining(source, `const ${anchor.constant}`), + line: anchorDeclarationLine(source, anchor), + role: anchor.role, + layer: anchor.layer, + context: anchor.context, }; }) .sort(byId); @@ -468,6 +526,9 @@ describe("the self-hosting corpus", () => { label: node.label, file: node.file, line: node.line, + role: node.nodeType === "CodeNode" ? node.role : undefined, + layer: node.nodeType === "CodeNode" ? node.layer : undefined, + context: node.nodeType === "CodeNode" ? node.context : undefined, })) .sort(byId), ).toEqual(expectedAnchorNodes); @@ -488,7 +549,7 @@ describe("the self-hosting corpus", () => { it("keeps every anchor beside the site it binds", () => { for (const anchor of expectedAnchors) { const source = readFileSync(join(repoRoot, anchor.file), "utf8"); - const anchorLine = lineContaining(source, `const ${anchor.constant}`); + const anchorLine = anchorDeclarationLine(source, anchor); const activation = adoptedActivationIdentifier(anchor.site); const siteLine = activation === undefined @@ -498,9 +559,9 @@ describe("the self-hosting corpus", () => { expect(anchorLine).toBeGreaterThan(0); expect(siteLine, anchor.id).toBeGreaterThan(0); - // The densest site has four adjacent member anchors; their required component rows add - // exactly four lines to the original 20-line locality bound. - expect(Math.abs(anchorLine - siteLine), anchor.id).toBeLessThanOrEqual(24); + // re-measured under plan 40 + // The densest adjacent binding group now spans 28 lines with component and role fields. + expect(Math.abs(anchorLine - siteLine), anchor.id).toBeLessThanOrEqual(28); expect(node).toMatchObject({ file: anchor.file, line: anchorLine, claim: "anchored" }); } }); diff --git a/test/self-hosting-oracle/anchors.ts b/test/self-hosting-oracle/anchors.ts index aaa62a33..f1274b8f 100644 --- a/test/self-hosting-oracle/anchors.ts +++ b/test/self-hosting-oracle/anchors.ts @@ -1,17 +1,29 @@ -// The authored anchors of the self-hosting corpus — a binding assertion each, identity only and -// never intent. `constant` names the anchor declaration and `site` the code it binds, so the +// The authored anchor nodes, independent of their zero-to-many binding edges. +// `constant` names a constant-form declaration; its absence selects the comment block. `site` +// names the code it binds, so the // suite can resolve both lines in the real file rather than trusting a transcribed line number. // A site written as a bare `register(` call marks an adopted generated registrar: the // suite resolves it as an executable top-level call through the TypeScript AST, so a comment or // string literal can never satisfy the locality invariant. -export const expectedAnchors = [ +export interface ExpectedAnchor { + readonly id: string; + readonly nodeType: "Anchor" | "CodeNode"; + readonly label: string; + readonly file: string; + readonly constant?: string; + readonly site: string; + readonly role?: string; + readonly layer?: string; + readonly context?: string; +} + +// re-measured under plan 40 +export const expectedAnchors: readonly ExpectedAnchor[] = [ { id: "test:protocol.open-section-order", nodeType: "Anchor", label: "verifies serialized authored entry order", - type: "verifies", - target: "spec:extraction.open-section-order", file: "test/graph-schema.test.ts", constant: "openSectionOrderTestAnchor", site: 'it("serializes authored entry order', @@ -20,168 +32,161 @@ export const expectedAnchors = [ id: "impl:protocol.open-section-order", nodeType: "CodeNode", label: "preserves authored open-section and model-term order", - type: "satisfies", - target: "spec:extraction.open-section-order", file: "src/extract/serialize.ts", constant: "openSectionOrderAnchor", site: "function canonicalDynamicSection", + role: "codec", }, { id: "component:protocol.model", nodeType: "CodeNode", label: "Protocol model seam", - type: "satisfies", - target: "spec:model.core-model", file: "src/model/anchors.ts", constant: "modelComponentAnchor", site: "const anchorModelAnchor", + layer: "domain", + context: "protocol", }, { id: "component:protocol.extract", nodeType: "CodeNode", label: "Protocol extraction seam", - type: "satisfies", - target: "spec:extraction.derive-graph", file: "src/extract/index.ts", constant: "extractComponentAnchor", site: "export const extractAnchor", + layer: "application", + context: "protocol", }, { id: "component:protocol.graph", nodeType: "CodeNode", label: "Protocol graph seam", - type: "satisfies", - target: "spec:extraction.claim-taxonomy", file: "src/graph/schema.ts", constant: "graphComponentAnchor", site: "const graphClaimsAnchor", + layer: "domain", + context: "protocol", }, { id: "component:protocol.validate", nodeType: "CodeNode", label: "Protocol validation seam", - type: "satisfies", - target: "spec:validation.two-check-families", file: "src/validate/validators.ts", constant: "validateComponentAnchor", site: "const validationFamiliesAnchor", + layer: "domain", + context: "protocol", }, { id: "component:protocol.reader", nodeType: "CodeNode", label: "Protocol reader seam", - type: "satisfies", - target: "spec:consumers.reader", file: "src/reader/reader.ts", - constant: "readerComponentAnchor", - site: "const readerAnchor", + site: "export function createReader", + layer: "application", + context: "protocol", }, { id: "component:protocol.projections", nodeType: "CodeNode", label: "Protocol projections seam", - type: "satisfies", - target: "spec:consumers.projections-model", file: "src/projections/design-review.ts", - constant: "projectionsComponentAnchor", - site: "const projectionModelAnchor", + site: "export function renderDesignReview", + layer: "edge", + context: "protocol", }, { id: "component:protocol.cli", nodeType: "CodeNode", label: "Protocol CLI seam", - type: "satisfies", - target: "spec:extraction.build-pipeline", file: "src/cli/build-command.ts", constant: "cliComponentAnchor", site: "export interface BuildHooks", + layer: "edge", + context: "protocol", }, { id: "component:protocol.runner", nodeType: "CodeNode", label: "Protocol example runner seam", - type: "satisfies", - target: "spec:extraction.example-runner", file: "src/runner/index.ts", constant: "runnerComponentAnchor", site: "const exampleRunnerAnchor", + layer: "application", + context: "protocol", }, { id: "component:protocol.codegen", nodeType: "CodeNode", label: "Protocol runnable code generation seam", - type: "satisfies", - target: "spec:extraction.runnable-modules", file: "src/codegen/contracts.ts", constant: "codegenComponentAnchor", site: "const runnableModulesAnchor", + layer: "application", + context: "protocol", }, { id: "component:protocol.notation", nodeType: "CodeNode", label: "Protocol slot notation seam", - type: "satisfies", - target: "spec:carrier.slot-notation", file: "src/notation/slots.ts", constant: "notationComponentAnchor", site: "const slotNotationAnchor", + layer: "domain", + context: "protocol", }, { id: "component:protocol.adapters", nodeType: "CodeNode", label: "Protocol Vitest adapter seam", - type: "satisfies", - target: "spec:extraction.example-runner", file: "src/adapters/vitest.ts", constant: "adaptersComponentAnchor", site: "const exampleRunnerAdapterAnchor", + layer: "adapter", + context: "protocol", }, { id: "component:protocol.import", nodeType: "CodeNode", label: "Protocol import seam", - type: "satisfies", - target: "spec:carrier.sdp-import", file: "src/import/import.ts", constant: "importComponentAnchor", site: "const sdpImportCoreAnchor", + layer: "application", + context: "protocol", }, { id: "component:protocol.testing", nodeType: "CodeNode", label: "Protocol testing seam", - type: "satisfies", - target: "spec:extraction.example-runner", file: "src/testing/index.ts", constant: "testingComponentAnchor", site: "const exampleTestingHelpersAnchor", + layer: "adapter", + context: "protocol", }, { id: "impl:protocol.extract", nodeType: "CodeNode", label: "extracts authored carriers and bindings into one graph", - type: "satisfies", - target: "spec:extraction.derive-graph", file: "src/extract/index.ts", constant: "extractAnchor", site: "export function extract", + role: "extractor", }, { id: "impl:protocol.derive-graph", nodeType: "CodeNode", label: "derives the graph from reified carriers and bindings", - type: "satisfies", - target: "spec:extraction.derive-graph", file: "src/extract/derive.ts", constant: "deriveGraphAnchor", site: "export function deriveGraph", + role: "extractor", }, { id: "test:protocol.extract", nodeType: "Anchor", label: "extraction contracts verify graph derivation", - type: "verifies", - target: "spec:extraction.derive-graph", file: "test/extract.test.ts", constant: "extractContractTestAnchor", site: 'describe("anchor extraction corpora",', @@ -190,8 +195,6 @@ export const expectedAnchors = [ id: "test:protocol.delivery-facts", nodeType: "Anchor", label: "verifies the delivery-fact conferral ladder and fail-closed posture", - type: "verifies", - target: "spec:extraction.delivery-facts", file: "test/extract.test.ts", constant: "deliveryFactsTestAnchor", site: 'it("anchored-binding: the full ladder', @@ -200,8 +203,6 @@ export const expectedAnchors = [ id: "test:protocol.structural-self-binding", nodeType: "Anchor", label: "verifies the accepted significant-unit set carries its declared membership", - type: "verifies", - target: "spec:protocol.structural-self-binding", file: "test/self-hosting-graph.test.ts", constant: "structuralSelfBindingTestAnchor", site: "void structuralSelfBindingTestAnchor", @@ -210,8 +211,6 @@ export const expectedAnchors = [ id: "test:protocol.extraction-determinism", nodeType: "Anchor", label: "clean-repo pipeline determinism verifies byte-identical output", - type: "verifies", - target: "spec:extraction.determinism", file: "test/cli.test.ts", constant: "cleanRepoDeterminismTestAnchor", site: 'it("clean-repo determinism: the full pipeline at a different absolute path is byte-identical"', @@ -220,8 +219,6 @@ export const expectedAnchors = [ id: "test:protocol.build-pipeline.same-invocation", nodeType: "Anchor", label: "the same-invocation point verifies the query extraction and validation seam", - type: "verifies", - target: "spec:extraction.build-pipeline.same-invocation", file: "test/self-hosting-extraction.test.ts", constant: "buildPipelineSameInvocationTestAnchor", site: "registerSameInvocation(", @@ -230,8 +227,6 @@ export const expectedAnchors = [ id: "impl:protocol.authoring-on-ramp", nodeType: "CodeNode", label: "asserts realization of the shipped graph-first authoring skill document", - type: "satisfies", - target: "spec:consumers.authoring-on-ramp", file: "test/skills.test.ts", constant: "authoringOnRampImplementationAnchor", site: "function readSkill", @@ -240,8 +235,6 @@ export const expectedAnchors = [ id: "impl:protocol.delivery-session-on-ramp", nodeType: "CodeNode", label: "asserts realization of the shipped advisory delivery-session skill document", - type: "satisfies", - target: "spec:consumers.delivery-session-on-ramp", file: "test/skills.test.ts", constant: "deliverySessionOnRampImplementationAnchor", site: "function readSkill", @@ -250,8 +243,6 @@ export const expectedAnchors = [ id: "impl:protocol.authoring-recipes", nodeType: "CodeNode", label: "asserts realization of the shipped authoring recipe catalog", - type: "satisfies", - target: "spec:consumers.agent-surface.authoring-recipes", file: "test/skills.test.ts", constant: "authoringRecipesImplementationAnchor", site: "function documentedCommands", @@ -260,8 +251,6 @@ export const expectedAnchors = [ id: "test:protocol.authoring-on-ramp", nodeType: "Anchor", label: "skill-asset checks verify the authoring on-ramp", - type: "verifies", - target: "spec:consumers.authoring-on-ramp", file: "test/skills.test.ts", constant: "authoringOnRampTestAnchor", site: 'describe("Protocol skill assets"', @@ -270,8 +259,6 @@ export const expectedAnchors = [ id: "test:protocol.delivery-session-on-ramp", nodeType: "Anchor", label: "skill-asset checks verify advisory delivery-session routing", - type: "verifies", - target: "spec:consumers.delivery-session-on-ramp", file: "test/skills.test.ts", constant: "deliverySessionOnRampTestAnchor", site: 'describe("Protocol skill assets"', @@ -280,8 +267,6 @@ export const expectedAnchors = [ id: "test:protocol.authoring-recipes", nodeType: "Anchor", label: "skill-asset checks verify the authoring recipes", - type: "verifies", - target: "spec:consumers.agent-surface.authoring-recipes", file: "test/skills.test.ts", constant: "authoringRecipesTestAnchor", site: 'describe("Protocol skill assets"', @@ -290,18 +275,15 @@ export const expectedAnchors = [ id: "impl:protocol.readiness-floor", nodeType: "CodeNode", label: "evaluates the stated readiness floor against the graph", - type: "satisfies", - target: "spec:validation.readiness-floor", file: "src/validate/readiness-floor.ts", constant: "readinessFloorAnchor", site: "export function evaluateReadinessFloor", + role: "validator", }, { id: "test:protocol.readiness-floor", nodeType: "Anchor", label: "readiness-floor contracts verify stated maturity", - type: "verifies", - target: "spec:validation.readiness-floor", file: "test/readiness.test.ts", constant: "readinessFloorTestAnchor", site: 'describe("readiness and validation contracts",', @@ -310,38 +292,33 @@ export const expectedAnchors = [ id: "impl:protocol.markdown-authoring", nodeType: "CodeNode", label: "reifies Markdown authoring into the one carrier path", - type: "satisfies", - target: "spec:carrier.markdown-authoring", file: "src/extract/markdown.ts", constant: "markdownAuthoringAnchor", site: "export function reifyMarkdownCarrier", + role: "extractor", }, { id: "impl:protocol.gherkin-authoring", nodeType: "CodeNode", label: "reifies Gherkin behavior and example authoring into the one carrier path", - type: "satisfies", - target: "spec:carrier.gherkin-authoring", file: "src/extract/gherkin.ts", constant: "gherkinAuthoringAnchor", site: "export function reifyGherkinCarrier", + role: "extractor", }, { id: "impl:protocol.markdown-parser", nodeType: "CodeNode", label: "reifies the ruled Markdown parser input", - type: "satisfies", - target: "spec:carrier.markdown-parser", file: "src/extract/markdown.ts", constant: "markdownParserAnchor", site: "export function reifyMarkdownCarrier", + role: "extractor", }, { id: "test:protocol.markdown-parser", nodeType: "Anchor", label: "Markdown reifier tests verify the ruled parser", - type: "verifies", - target: "spec:carrier.markdown-parser", file: "test/markdown-reifier.test.ts", constant: "markdownParserTestAnchor", site: 'describe("Markdown frontmatter reifier",', @@ -350,18 +327,15 @@ export const expectedAnchors = [ id: "impl:protocol.envelope-contract", nodeType: "CodeNode", label: "parses the bounded Markdown frontmatter envelope", - type: "satisfies", - target: "spec:carrier.envelope-contract", file: "src/extract/markdown.ts", constant: "envelopeContractAnchor", site: "export function parseMarkdownFrontmatter", + role: "codec", }, { id: "test:protocol.envelope-contract", nodeType: "Anchor", label: "frontmatter contract tests verify the Markdown envelope", - type: "verifies", - target: "spec:carrier.envelope-contract", file: "test/markdown-reifier.test.ts", constant: "envelopeContractTestAnchor", site: 'describe("Markdown frontmatter reifier",', @@ -370,18 +344,15 @@ export const expectedAnchors = [ id: "impl:protocol.prose-ownership", nodeType: "CodeNode", label: "reads Markdown body content through its prose owners", - type: "satisfies", - target: "spec:carrier.prose-ownership-rule", file: "src/extract/markdown.ts", constant: "proseOwnershipAnchor", site: "export function readMarkdownBody", + role: "codec", }, { id: "test:protocol.prose-ownership", nodeType: "Anchor", label: "Markdown reifier tests verify prose ownership", - type: "verifies", - target: "spec:carrier.prose-ownership-rule", file: "test/markdown-reifier.test.ts", constant: "proseOwnershipTestAnchor", site: 'describe("Markdown frontmatter reifier",', @@ -390,18 +361,15 @@ export const expectedAnchors = [ id: "impl:protocol.duplicate-id-exclusion", nodeType: "CodeNode", label: "excludes duplicated carrier ids from the graph", - type: "satisfies", - target: "spec:validation.duplicate-ids", file: "src/extract/index.ts", constant: "duplicateIdExclusionAnchor", site: "function findDuplicatedIds", + role: "validator", }, { id: "test:protocol.duplicate-ids.dual-carrier", nodeType: "Anchor", label: "dual-carrier duplicate-ID contract verifies carrier exclusion", - type: "verifies", - target: "spec:validation.duplicate-ids.dual-carrier", file: "test/self-hosting-duplicate-ids.test.ts", constant: "dualCarrierDuplicateTestAnchor", site: "registerDualCarrier(", @@ -410,8 +378,6 @@ export const expectedAnchors = [ id: "test:protocol.warn-level-signals.orphan-signal", nodeType: "Anchor", label: "the orphan point verifies the disconnected-spec warning", - type: "verifies", - target: "spec:validation.warn-level-signals.orphan-signal", file: "test/self-hosting-validators.test.ts", constant: "warnLevelOrphanTestAnchor", site: "registerOrphanSignal(", @@ -420,8 +386,6 @@ export const expectedAnchors = [ id: "test:protocol.warn-level-signals.ready-gap-signal", nodeType: "Anchor", label: "the gap point verifies the unverified-ready warning", - type: "verifies", - target: "spec:validation.warn-level-signals.ready-gap-signal", file: "test/self-hosting-validators.test.ts", constant: "warnLevelGapTestAnchor", site: "registerReadyGapSignal(", @@ -430,8 +394,6 @@ export const expectedAnchors = [ id: "test:protocol.readiness-floor.unrelated-scoped-spec", nodeType: "Anchor", label: "the unrelated-scoped point verifies the relation clause of the scoped rung", - type: "verifies", - target: "spec:validation.readiness-floor.unrelated-scoped-spec", file: "test/self-hosting-validators.test.ts", constant: "unrelatedScopedSpecTestAnchor", site: "registerUnrelatedScopedSpec(", @@ -440,8 +402,6 @@ export const expectedAnchors = [ id: "test:protocol.readiness-floor.blocking-open-question", nodeType: "Anchor", label: "the blocked-question point verifies the open-questions clause of the defined rung", - type: "verifies", - target: "spec:validation.readiness-floor.blocking-open-question", file: "test/self-hosting-validators.test.ts", constant: "blockingOpenQuestionTestAnchor", site: "registerBlockingOpenQuestion(", @@ -450,8 +410,6 @@ export const expectedAnchors = [ id: "test:protocol.kind-evidence.constraints-alone", nodeType: "Anchor", label: "the constraints-alone point verifies the behavior-family complete cell", - type: "verifies", - target: "spec:validation.kind-evidence.constraints-alone", file: "test/self-hosting-validators.test.ts", constant: "constraintsAloneTestAnchor", site: "registerConstraintsAlone(", @@ -460,8 +418,6 @@ export const expectedAnchors = [ id: "test:protocol.kind-evidence.untargeted-constraint", nodeType: "Anchor", label: "the untargeted-constraint point verifies the constraint row's target requirement", - type: "verifies", - target: "spec:validation.kind-evidence.untargeted-constraint", file: "test/self-hosting-validators.test.ts", constant: "untargetedConstraintTestAnchor", site: "registerUntargetedConstraint(", @@ -470,8 +426,6 @@ export const expectedAnchors = [ id: "test:protocol.kind-evidence.empty-promoted-child", nodeType: "Anchor", label: "the empty-promotion point verifies the promoted-evidence honesty bound", - type: "verifies", - target: "spec:validation.kind-evidence.empty-promoted-child", file: "test/self-hosting-validators.test.ts", constant: "emptyPromotedChildTestAnchor", site: "registerEmptyPromotedChild(", @@ -480,8 +434,6 @@ export const expectedAnchors = [ id: "test:protocol.referential-integrity.dangling-target", nodeType: "Anchor", label: "the dangling-target point verifies the unresolved-reference error", - type: "verifies", - target: "spec:validation.referential-integrity.dangling-target", file: "test/self-hosting-validators.test.ts", constant: "danglingTargetTestAnchor", site: "registerDanglingTarget(", @@ -490,8 +442,6 @@ export const expectedAnchors = [ id: "test:protocol.referential-integrity.did-you-mean", nodeType: "Anchor", label: "the near-miss point verifies the unique did-you-mean suggestion", - type: "verifies", - target: "spec:validation.referential-integrity.did-you-mean", file: "test/self-hosting-validators.test.ts", constant: "didYouMeanTestAnchor", site: "registerDidYouMean(", @@ -500,8 +450,6 @@ export const expectedAnchors = [ id: "test:protocol.authored-honesty.section-authored-fact", nodeType: "Anchor", label: "the section point verifies the authoring-shape refusal", - type: "verifies", - target: "spec:validation.authored-honesty.section-authored-fact", file: "test/self-hosting-validators.test.ts", constant: "sectionAuthoredFactTestAnchor", site: "registerSectionAuthoredFact(", @@ -510,8 +458,6 @@ export const expectedAnchors = [ id: "test:protocol.authored-honesty.unearned-stated-fact", nodeType: "Anchor", label: "the stated-fact point verifies the delivery-fact refusal", - type: "verifies", - target: "spec:validation.authored-honesty.unearned-stated-fact", file: "test/self-hosting-validators.test.ts", constant: "unearnedStatedFactTestAnchor", site: "registerUnearnedStatedFact(", @@ -520,8 +466,6 @@ export const expectedAnchors = [ id: "test:protocol.claim-separation.collapsed-edge-claim", nodeType: "Anchor", label: "the collapsed-claim point verifies the binding-edge contract row", - type: "verifies", - target: "spec:validation.claim-separation.collapsed-edge-claim", file: "test/self-hosting-validators.test.ts", constant: "collapsedEdgeClaimTestAnchor", site: "registerCollapsedEdgeClaim(", @@ -530,8 +474,6 @@ export const expectedAnchors = [ id: "test:protocol.claim-separation.unratified-descriptor", nodeType: "Anchor", label: "the unratified-kind point verifies the fail-closed descriptor law", - type: "verifies", - target: "spec:validation.claim-separation.unratified-descriptor", file: "test/self-hosting-validators.test.ts", constant: "unratifiedDescriptorTestAnchor", site: "registerUnratifiedDescriptor(", @@ -540,8 +482,6 @@ export const expectedAnchors = [ id: "test:protocol.verification-linkage.unbound-example", nodeType: "Anchor", label: "the unbound-example point verifies the incomplete spec-to-test trace", - type: "verifies", - target: "spec:validation.verification-linkage.unbound-example", file: "test/self-hosting-validators.test.ts", constant: "unboundExampleTestAnchor", site: "registerUnboundExample(", @@ -550,8 +490,6 @@ export const expectedAnchors = [ id: "test:protocol.verification-linkage.unresolved-oracle", nodeType: "Anchor", label: "the unresolved-oracle point verifies the oracle binding refusal", - type: "verifies", - target: "spec:validation.verification-linkage.unresolved-oracle", file: "test/self-hosting-validators.test.ts", constant: "unresolvedOracleTestAnchor", site: "registerUnresolvedOracle(", @@ -560,8 +498,6 @@ export const expectedAnchors = [ id: "test:protocol.pack-coherence.incoherent-aggregate", nodeType: "Anchor", label: "the incoherent-aggregate point verifies both halves of the pack law", - type: "verifies", - target: "spec:validation.pack-coherence.incoherent-aggregate", file: "test/self-hosting-validators.test.ts", constant: "incoherentAggregateTestAnchor", site: "registerIncoherentAggregate(", @@ -570,8 +506,6 @@ export const expectedAnchors = [ id: "test:protocol.excludes.segment-boundary", nodeType: "Anchor", label: "the segment-boundary point verifies the exact-prefix exclusion rule", - type: "verifies", - target: "spec:extraction.excludes.segment-boundary", file: "test/self-hosting-extraction.test.ts", constant: "excludesSegmentBoundaryTestAnchor", site: "registerSegmentBoundary(", @@ -580,8 +514,6 @@ export const expectedAnchors = [ id: "test:protocol.excludes.refused-path", nodeType: "Anchor", label: "the refused-path point verifies the malformed-exclusion refusal", - type: "verifies", - target: "spec:extraction.excludes.refused-path", file: "test/self-hosting-extraction.test.ts", constant: "excludesRefusedPathTestAnchor", site: "registerRefusedPath(", @@ -590,8 +522,6 @@ export const expectedAnchors = [ id: "test:protocol.schema-versioning.declared-version", nodeType: "Anchor", label: "the declared-version point verifies the readable payload version", - type: "verifies", - target: "spec:extraction.schema-versioning.declared-version", file: "test/self-hosting-extraction.test.ts", constant: "schemaVersioningTestAnchor", site: "registerDeclaredVersion(", @@ -600,8 +530,6 @@ export const expectedAnchors = [ id: "test:protocol.stable-ids.namespaced-round-trip", nodeType: "Anchor", label: "the round-trip point verifies the namespaced dotted-path grammar", - type: "verifies", - target: "spec:model.stable-ids.namespaced-round-trip", file: "test/self-hosting-model.test.ts", constant: "namespacedRoundTripTestAnchor", site: "registerNamespacedRoundTrip(", @@ -610,8 +538,6 @@ export const expectedAnchors = [ id: "test:protocol.stable-ids.malformed-refusal", nodeType: "Anchor", label: "the malformed point verifies the lowercase-namespace refusal", - type: "verifies", - target: "spec:model.stable-ids.malformed-refusal", file: "test/self-hosting-model.test.ts", constant: "malformedRefusalTestAnchor", site: "registerMalformedRefusal(", @@ -620,8 +546,6 @@ export const expectedAnchors = [ id: "test:protocol.stable-ids.unsectioned-address-refused", nodeType: "Anchor", label: "the unsectioned point verifies the entry-address section refusal", - type: "verifies", - target: "spec:model.stable-ids.unsectioned-address-refused", file: "test/self-hosting-model.test.ts", constant: "unsectionedAddressRefusedTestAnchor", site: "registerUnsectionedAddressRefused(", @@ -630,8 +554,6 @@ export const expectedAnchors = [ id: "test:protocol.markdown-parser.bounded-parity", nodeType: "Anchor", label: "the bounded-parity point verifies one shared finding class and its split outcomes", - type: "verifies", - target: "spec:carrier.markdown-parser.bounded-parity", file: "test/self-hosting-carrier.test.ts", constant: "boundedParityTestAnchor", site: "registerBoundedParity(", @@ -640,8 +562,6 @@ export const expectedAnchors = [ id: "test:protocol.gherkin-authoring.authored-fact-refused", nodeType: "Anchor", label: "the authored-fact-refused point verifies the Gherkin carrier contract", - type: "verifies", - target: "spec:carrier.gherkin-authoring.authored-fact-refused", file: "test/self-hosting-carrier-gherkin.test.ts", constant: "authoredFactRefusedAnchor", site: "registerAuthoredFactRefused(", @@ -650,8 +570,6 @@ export const expectedAnchors = [ id: "test:protocol.gherkin-authoring.contract-parity", nodeType: "Anchor", label: "the contract-parity point verifies the Gherkin carrier contract", - type: "verifies", - target: "spec:carrier.gherkin-authoring.contract-parity", file: "test/self-hosting-carrier-gherkin.test.ts", constant: "contractParityAnchor", site: "bindExample(contractParityContract", @@ -660,8 +578,6 @@ export const expectedAnchors = [ id: "test:protocol.gherkin-authoring.duplicate-surface-refused", nodeType: "Anchor", label: "the duplicate-surface-refused point verifies the Gherkin carrier contract", - type: "verifies", - target: "spec:carrier.gherkin-authoring.duplicate-surface-refused", file: "test/self-hosting-carrier-gherkin.test.ts", constant: "duplicateSurfaceRefusedAnchor", site: "registerDuplicateSurfaceRefused(", @@ -670,8 +586,6 @@ export const expectedAnchors = [ id: "test:protocol.gherkin-authoring.example-space-extraction", nodeType: "Anchor", label: "the example-space-extraction point verifies the Gherkin carrier contract", - type: "verifies", - target: "spec:carrier.gherkin-authoring.example-space-extraction", file: "test/self-hosting-carrier-gherkin.test.ts", constant: "exampleSpaceExtractionAnchor", site: "registerExampleSpaceExtraction(", @@ -680,8 +594,6 @@ export const expectedAnchors = [ id: "test:protocol.gherkin-authoring.malformed-relation-refused", nodeType: "Anchor", label: "the malformed-relation-refused point verifies the Gherkin carrier contract", - type: "verifies", - target: "spec:carrier.gherkin-authoring.malformed-relation-refused", file: "test/self-hosting-carrier-gherkin.test.ts", constant: "malformedRelationRefusedAnchor", site: "registerMalformedRelationRefused(", @@ -690,8 +602,6 @@ export const expectedAnchors = [ id: "test:protocol.gherkin-authoring.missing-id-refused", nodeType: "Anchor", label: "the missing-id-refused point verifies the Gherkin carrier contract", - type: "verifies", - target: "spec:carrier.gherkin-authoring.missing-id-refused", file: "test/self-hosting-carrier-gherkin.test.ts", constant: "missingIdRefusedAnchor", site: "registerMissingIdRefused(", @@ -700,8 +610,6 @@ export const expectedAnchors = [ id: "test:protocol.gherkin-authoring.parent-child-extraction", nodeType: "Anchor", label: "the parent-child-extraction point verifies the Gherkin carrier contract", - type: "verifies", - target: "spec:carrier.gherkin-authoring.parent-child-extraction", file: "test/self-hosting-carrier-gherkin.test.ts", constant: "parentChildExtractionAnchor", site: "registerParentChildExtraction(", @@ -710,8 +618,6 @@ export const expectedAnchors = [ id: "test:protocol.gherkin-authoring.unbound-ready-refused", nodeType: "Anchor", label: "the unbound-ready-refused point verifies the Gherkin carrier contract", - type: "verifies", - target: "spec:carrier.gherkin-authoring.unbound-ready-refused", file: "test/self-hosting-carrier-gherkin.test.ts", constant: "unboundReadyRefusedAnchor", site: "bindExample(unboundReadyRefusedContract", @@ -720,8 +626,6 @@ export const expectedAnchors = [ id: "test:protocol.gherkin-authoring.unknown-tag-refused", nodeType: "Anchor", label: "the unknown-tag-refused point verifies the Gherkin carrier contract", - type: "verifies", - target: "spec:carrier.gherkin-authoring.unknown-tag-refused", file: "test/self-hosting-carrier-gherkin.test.ts", constant: "unknownTagRefusedAnchor", site: "registerUnknownTagRefused(", @@ -730,8 +634,6 @@ export const expectedAnchors = [ id: "test:protocol.gherkin-authoring.unsupported-construct-refused", nodeType: "Anchor", label: "the unsupported-construct-refused point verifies the Gherkin carrier contract", - type: "verifies", - target: "spec:carrier.gherkin-authoring.unsupported-construct-refused", file: "test/self-hosting-carrier-gherkin.test.ts", constant: "unsupportedConstructRefusedAnchor", site: "registerUnsupportedConstructRefused(", @@ -740,8 +642,6 @@ export const expectedAnchors = [ id: "test:protocol.gherkin-authoring.description-location-refused", nodeType: "Anchor", label: "the description-location-refused point verifies the Gherkin carrier contract", - type: "verifies", - target: "spec:carrier.gherkin-authoring.description-location-refused", file: "test/self-hosting-carrier-gherkin.test.ts", constant: "descriptionLocationRefusedAnchor", site: "registerDescriptionLocationRefused(", @@ -750,8 +650,6 @@ export const expectedAnchors = [ id: "test:protocol.gherkin-authoring.step-less-scenario-refused", nodeType: "Anchor", label: "the step-less-scenario-refused point verifies the Gherkin carrier contract", - type: "verifies", - target: "spec:carrier.gherkin-authoring.step-less-scenario-refused", file: "test/self-hosting-carrier-gherkin.test.ts", constant: "stepLessScenarioRefusedAnchor", site: "registerStepLessScenarioRefused(", @@ -760,8 +658,6 @@ export const expectedAnchors = [ id: "test:protocol.gherkin-authoring.multi-finding-bounded", nodeType: "Anchor", label: "the multi-finding-bounded point verifies the Gherkin carrier contract", - type: "verifies", - target: "spec:carrier.gherkin-authoring.multi-finding-bounded", file: "test/self-hosting-carrier-gherkin.test.ts", constant: "multiFindingBoundedAnchor", site: "registerMultiFindingBounded(", @@ -770,8 +666,6 @@ export const expectedAnchors = [ id: "test:protocol.sdp-import.round-trip", nodeType: "Anchor", label: "TypeScript import round-trip contract preserves authored data", - type: "verifies", - target: "spec:carrier.sdp-import.round-trip", file: "test/self-hosting-sdp-import.test.ts", constant: "sdpImportRoundTripTestAnchor", site: "registerRoundTrip(", @@ -779,189 +673,169 @@ export const expectedAnchors = [ { id: "impl:protocol.anchor-extraction", nodeType: "CodeNode", - label: "anchor-constant reification seam", - type: "satisfies", - target: "spec:model.anchors", + label: "anchor reification seam: the constant form and the comment form", file: "src/extract/anchors.ts", constant: "anchorExtractionAnchor", - site: "const ANCHOR_BUILDER_TARGET_FIELDS", + site: "export interface ReifiedAnchor", + role: "extractor", }, { id: "impl:protocol.anchor-model", nodeType: "CodeNode", label: "binding-only anchor model builders", - type: "satisfies", - target: "spec:model.anchors", file: "src/model/anchors.ts", constant: "anchorModelAnchor", site: "export function specTest", + role: "contract", + }, + { + id: "impl:protocol.anchors-subpath", + nodeType: "CodeNode", + label: "zero-dependency subpath exporting the id and anchor builders only", + file: "src/anchors.ts", + site: "export {", + role: "contract", }, { id: "impl:protocol.pack-aggregate", nodeType: "CodeNode", label: "Pack aggregate and model references", - type: "satisfies", - target: "spec:model.pack-aggregate", file: "src/model/pack.ts", constant: "packAggregateAnchor", site: "export function pack", + role: "contract", }, { id: "impl:protocol.spec-descriptors", nodeType: "CodeNode", label: "Spec kind, altitude, and readiness coordinates", - type: "satisfies", - target: "spec:model.core-model", file: "src/model/descriptors.ts", constant: "specDescriptorsAnchor", site: "export const SPEC_KIND_DISPLAY_LABELS", + role: "contract", }, { id: "impl:protocol.spec-primitive", nodeType: "CodeNode", label: "Spec envelope and enrich-in-place shape", - type: "satisfies", - target: "spec:model.core-model", file: "src/model/spec.ts", constant: "specPrimitiveAnchor", site: "export function spec", + role: "contract", }, { id: "impl:protocol.spec-relations", nodeType: "CodeNode", label: "declared Spec relation builders", - type: "satisfies", - target: "spec:model.relations", file: "src/model/relations.ts", constant: "specRelationsAnchor", site: "export function supersedes", + role: "contract", }, { id: "impl:protocol.spec-sections", nodeType: "CodeNode", label: "typed Spec section shapes", - type: "satisfies", - target: "spec:model.spec-sections", file: "src/model/sections.ts", constant: "specSectionsAnchor", site: "export interface SpecSections", + role: "contract", }, { id: "impl:protocol.stable-ids", nodeType: "CodeNode", label: "stable ID grammar parser", - type: "satisfies", - target: "spec:model.stable-ids", file: "src/ids.ts", constant: "stableIdsAnchor", site: "export function parseId", + role: "codec", }, { id: "impl:protocol.verifier-semantics", nodeType: "CodeNode", label: "readiness clauses over direct verification bindings", - type: "satisfies", - target: "spec:model.spec-sections", file: "src/validate/readiness-floor.ts", constant: "verifierSemanticsAnchor", site: "export function evaluateReadinessFloor", + role: "validator", }, { id: "impl:protocol.validation-families", nodeType: "CodeNode", label: "conformance and honesty validator registry", - type: "satisfies", - target: "spec:validation.two-check-families", file: "src/validate/validators.ts", constant: "validationFamiliesAnchor", site: "export const graphValidatorIds", + role: "contract", }, { id: "impl:protocol.projections-model", nodeType: "CodeNode", label: "pure generated projection page contract", - type: "satisfies", - target: "spec:consumers.projections-model", file: "src/projections/design-review.ts", - constant: "projectionModelAnchor", site: "export interface DesignReviewPage", + role: "contract", }, { id: "impl:protocol.agent-surface", nodeType: "CodeNode", label: "typed graph reader and agent entry adapters", - type: "satisfies", - target: "spec:consumers.agent-surface", file: "src/reader/reader.ts", - constant: "agentSurfaceAnchor", site: "export function createReader", + role: "reader", }, { id: "impl:protocol.reader-impact", nodeType: "CodeNode", label: "file-level reader blast-radius contract", - type: "satisfies", - target: "spec:consumers.reader", file: "src/reader/reader.ts", - constant: "readerImpactAnchor", site: "export interface BlastRadius", + role: "contract", }, { id: "impl:protocol.reader", nodeType: "CodeNode", label: "thin typed graph reader construction", - type: "satisfies", - target: "spec:consumers.reader", file: "src/reader/reader.ts", - constant: "readerAnchor", site: "export function createReader", + role: "reader", }, { id: "impl:protocol.design-review", nodeType: "CodeNode", label: "renders the contextual Design Review projection", - type: "satisfies", - target: "spec:consumers.design-review", file: "src/projections/design-review.ts", - constant: "designReviewAnchor", site: "export function renderDesignReview", + role: "projection", }, { id: "impl:protocol.census-page", nodeType: "CodeNode", label: "renders the derived census and runtime taxonomy projection", - type: "satisfies", - target: "spec:consumers.census-page", file: "src/projections/census.ts", - constant: "censusAnchor", site: "function compareCodeUnits", + role: "projection", }, { id: "impl:protocol.mermaid-view", nodeType: "CodeNode", label: "renders bounded Spec one-hop and Pack membership diagrams", - type: "satisfies", - target: "spec:consumers.mermaid-view", file: "src/projections/mermaid.ts", - constant: "mermaidViewAnchor", site: "function compareCodeUnits", + role: "projection", }, { id: "impl:protocol.gherkin-view", nodeType: "CodeNode", label: "renders a generated Gherkin-shaped READ projection of any Spec", - type: "satisfies", - target: "spec:consumers.gherkin-view", file: "src/projections/gherkin-view.ts", - constant: "gherkinViewAnchor", site: "const CANONICAL_KINDS", + role: "projection", }, { id: "test:protocol.gherkin-view", nodeType: "Anchor", label: "verifies the generated Gherkin-shaped READ projection", - type: "verifies", - target: "spec:consumers.gherkin-view", file: "test/gherkin-view.test.ts", constant: "gherkinViewTestAnchor", site: "const checkoutRoot", @@ -970,78 +844,78 @@ export const expectedAnchors = [ id: "impl:protocol.exclusion-surface", nodeType: "CodeNode", label: "strict root-relative exclusion input for both extraction surfaces", - type: "satisfies", - target: "spec:extraction.excludes", file: "src/extract/index.ts", constant: "exclusionSurfaceAnchor", site: "export interface ExtractOptions", + role: "contract", }, { id: "impl:protocol.graph-claims", nodeType: "CodeNode", label: "declares the graph claim taxonomy", - type: "satisfies", - target: "spec:extraction.claim-taxonomy", file: "src/graph/schema.ts", constant: "graphClaimsAnchor", site: "export const graphClaims", + role: "contract", + }, + { + id: "impl:protocol.graph-edge-types", + nodeType: "CodeNode", + label: "declares the closed list of graph edge types", + file: "src/graph/schema.ts", + constant: "graphEdgeTypesAnchor", + site: "export const graphEdgeTypes", + role: "contract", }, { id: "impl:protocol.regenerability", nodeType: "CodeNode", label: "repeats graph and contract producers for deterministic regeneration", - type: "satisfies", - target: "spec:extraction.regenerability", file: "src/cli/build-command.ts", constant: "regenerabilityAnchor", site: "export function runBuild", + role: "service", }, { id: "impl:protocol.schema-version", nodeType: "CodeNode", label: "declares the graph schema version", - type: "satisfies", - target: "spec:extraction.schema-versioning", file: "src/graph/schema.ts", constant: "schemaVersionAnchor", site: "export const schemaVersion", + role: "contract", }, { id: "impl:protocol.executable-contracts", nodeType: "CodeNode", label: "derives step and example-space contracts from the graph", - type: "satisfies", - target: "spec:extraction.executable-contracts", file: "src/codegen/contracts.ts", constant: "executableContractsAnchor", site: "export function generateContracts", + role: "projection", }, { id: "impl:protocol.example-runner", nodeType: "CodeNode", label: "plans and executes a bound example against the caller's world", - type: "satisfies", - target: "spec:extraction.example-runner", file: "src/runner/index.ts", constant: "exampleRunnerAnchor", site: "export function planExample", + role: "service", }, { id: "impl:protocol.slot-notation", nodeType: "CodeNode", label: "parses slot groups and normalizes a step to its skeleton", - type: "satisfies", - target: "spec:carrier.slot-notation", file: "src/notation/slots.ts", constant: "slotNotationAnchor", site: "export function parseSlots", + role: "codec", }, { id: "test:protocol.executable-contracts.concreteness-refusal", nodeType: "Anchor", label: "the concreteness point verifies the unbound-slot refusal", - type: "verifies", - target: "spec:extraction.executable-contracts.concreteness-refusal", file: "test/self-hosting-extraction.test.ts", constant: "concretenessRefusalTestAnchor", site: "bindExample(concretenessRefusalContract", @@ -1050,8 +924,6 @@ export const expectedAnchors = [ id: "test:protocol.executable-contracts.multi-entry-example", nodeType: "Anchor", label: "the multi-entry point verifies the named second entry", - type: "verifies", - target: "spec:extraction.executable-contracts.multi-entry-example", file: "test/self-hosting-extraction.test.ts", constant: "multiEntryExampleTestAnchor", site: "bindExample(multiEntryExampleContract", @@ -1060,8 +932,6 @@ export const expectedAnchors = [ id: "test:protocol.executable-contracts.case-colliding-path", nodeType: "Anchor", label: "the collision point verifies the all-or-nothing withholding", - type: "verifies", - target: "spec:extraction.executable-contracts.case-colliding-path", file: "test/self-hosting-extraction.test.ts", constant: "caseCollidingPathTestAnchor", site: "bindExample(caseCollidingPathContract", @@ -1070,8 +940,6 @@ export const expectedAnchors = [ id: "test:protocol.example-runner.step-order", nodeType: "Anchor", label: "the step-order point verifies contract order and the one handler per step", - type: "verifies", - target: "spec:extraction.example-runner.step-order", file: "test/self-hosting-extraction.test.ts", constant: "exampleRunnerStepOrderTestAnchor", site: "bindExample(stepOrderContract", @@ -1080,8 +948,6 @@ export const expectedAnchors = [ id: "test:protocol.example-runner.red-step-naming", nodeType: "Anchor", label: "the red-step point verifies the self-naming failure law", - type: "verifies", - target: "spec:extraction.example-runner.red-step-naming", file: "test/self-hosting-extraction.test.ts", constant: "exampleRunnerRedStepTestAnchor", site: "registerRedStepNaming(", @@ -1090,8 +956,6 @@ export const expectedAnchors = [ id: "test:protocol.slot-notation.typed-declaration", nodeType: "Anchor", label: "the declaration point verifies the typed form and its skeleton", - type: "verifies", - target: "spec:carrier.slot-notation.typed-declaration", file: "test/self-hosting-carrier.test.ts", constant: "slotNotationTypedTestAnchor", site: "registerTypedDeclaration(", @@ -1100,8 +964,6 @@ export const expectedAnchors = [ id: "test:protocol.slot-notation.refused-guess", nodeType: "Anchor", label: "the refusal point verifies prose braces and the unusable slot", - type: "verifies", - target: "spec:carrier.slot-notation.refused-guess", file: "test/self-hosting-carrier.test.ts", constant: "slotNotationRefusedTestAnchor", site: "registerRefusedGuess(", @@ -1110,8 +972,6 @@ export const expectedAnchors = [ id: "test:protocol.anchors.lookalike-refusal", nodeType: "Anchor", label: "the lookalike point verifies that a consumer-local builder mints nothing", - type: "verifies", - target: "spec:model.anchors.lookalike-refusal", file: "test/self-hosting-model.test.ts", constant: "lookalikeRefusalTestAnchor", site: "registerLookalikeRefusal(", @@ -1120,8 +980,6 @@ export const expectedAnchors = [ id: "test:protocol.anchors.physical-identity", nodeType: "Anchor", label: "the physical-identity point verifies the resolved relative builder import", - type: "verifies", - target: "spec:model.anchors.physical-identity", file: "test/self-hosting-model.test.ts", constant: "physicalIdentityTestAnchor", site: "registerPhysicalIdentity(", @@ -1130,8 +988,6 @@ export const expectedAnchors = [ id: "test:protocol.two-check-families.split-report", nodeType: "Anchor", label: "the split-report point verifies both families in one aggregate report", - type: "verifies", - target: "spec:validation.two-check-families.split-report", file: "test/self-hosting-validators.test.ts", constant: "splitReportTestAnchor", site: "registerSplitReport(", @@ -1140,8 +996,6 @@ export const expectedAnchors = [ id: "test:protocol.derived-readiness-banner.dishonest-divergence", nodeType: "Anchor", label: "the overstated-rung point verifies the banner and its first unmet clause", - type: "verifies", - target: "spec:consumers.derived-readiness-banner.dishonest-divergence", file: "test/self-hosting-projections.test.ts", constant: "dishonestDivergenceTestAnchor", site: "registerDishonestDivergence(", @@ -1150,8 +1004,6 @@ export const expectedAnchors = [ id: "test:protocol.derived-readiness-banner.honest-headroom", nodeType: "Anchor", label: "the understated-rung point verifies the one-direction bound on the banner", - type: "verifies", - target: "spec:consumers.derived-readiness-banner.honest-headroom", file: "test/self-hosting-projections.test.ts", constant: "honestHeadroomTestAnchor", site: "registerHonestHeadroom(", @@ -1160,8 +1012,6 @@ export const expectedAnchors = [ id: "test:protocol.binding-language-views.bound-spec-page", nodeType: "Anchor", label: "the bound-surface point verifies the rendered binding vocabulary", - type: "verifies", - target: "spec:consumers.binding-language-views.bound-spec-page", file: "test/self-hosting-projections.test.ts", constant: "boundSpecPageTestAnchor", site: "bindExample(boundSpecPageContract", @@ -1170,8 +1020,6 @@ export const expectedAnchors = [ id: "test:protocol.binding-language-views.pack-member-table", nodeType: "Anchor", label: "the pack-member point verifies the aggregate half of the rendered binding vocabulary", - type: "verifies", - target: "spec:consumers.binding-language-views.pack-member-table", file: "test/self-hosting-projections.test.ts", constant: "packMemberTableTestAnchor", site: "registerPackMemberTable(", @@ -1180,8 +1028,6 @@ export const expectedAnchors = [ id: "test:protocol.wholesale-view-rewrite.stale-page-removed", nodeType: "Anchor", label: "the stale-page point verifies the wholesale rewrite of the view directory", - type: "verifies", - target: "spec:consumers.wholesale-view-rewrite.stale-page-removed", file: "test/self-hosting-projections.test.ts", constant: "stalePageRemovedTestAnchor", site: "registerStalePageRemoved(", @@ -1190,8 +1036,6 @@ export const expectedAnchors = [ id: "test:protocol.wholesale-view-rewrite.late-stale-page", nodeType: "Anchor", label: "the late-page point verifies that the swap itself evicts what invalidation missed", - type: "verifies", - target: "spec:consumers.wholesale-view-rewrite.late-stale-page", file: "test/self-hosting-projections.test.ts", constant: "lateStalePageTestAnchor", site: "registerLateStalePage(", @@ -1200,8 +1044,6 @@ export const expectedAnchors = [ id: "test:protocol.wholesale-view-rewrite.failed-run-view-removed", nodeType: "Anchor", label: "the failed-run point verifies that an uncertifiable view is removed, not left readable", - type: "verifies", - target: "spec:consumers.wholesale-view-rewrite.failed-run-view-removed", file: "test/self-hosting-projections.test.ts", constant: "failedRunViewRemovedTestAnchor", site: "registerFailedRunViewRemoved(", @@ -1210,8 +1052,6 @@ export const expectedAnchors = [ id: "test:protocol.wholesale-view-rewrite.build-invalidates-view", nodeType: "Anchor", label: "the build point verifies the up-front invalidation on a command that renders no view", - type: "verifies", - target: "spec:consumers.wholesale-view-rewrite.build-invalidates-view", file: "test/self-hosting-projections.test.ts", constant: "buildInvalidatesViewTestAnchor", site: "registerBuildInvalidatesView(", @@ -1220,8 +1060,6 @@ export const expectedAnchors = [ id: "test:protocol.diagnostic-rendering.composed-location", nodeType: "Anchor", label: "the composed-location point verifies the one rendering rule and both degradations", - type: "verifies", - target: "spec:validation.diagnostic-rendering.composed-location", file: "test/self-hosting-projections.test.ts", constant: "composedLocationTestAnchor", site: "registerComposedLocation(", @@ -1230,8 +1068,6 @@ export const expectedAnchors = [ id: "test:protocol.diagnostic-rendering.table-cell-location", nodeType: "Anchor", label: "the table-cell point verifies the same composition rule on the Design Review's twin", - type: "verifies", - target: "spec:validation.diagnostic-rendering.table-cell-location", file: "test/self-hosting-projections.test.ts", constant: "tableCellLocationTestAnchor", site: "registerTableCellLocation(", @@ -1240,8 +1076,6 @@ export const expectedAnchors = [ id: "test:protocol.design-review.pure-projection", nodeType: "Anchor", label: "the pure-projection point verifies the view's own law over a whole-pipeline root", - type: "verifies", - target: "spec:consumers.design-review.pure-projection", file: "test/self-hosting-projections.test.ts", constant: "pureProjectionTestAnchor", site: "registerPureProjection(", @@ -1250,8 +1084,6 @@ export const expectedAnchors = [ id: "test:protocol.agent-surface.scripted-context-body", nodeType: "Anchor", label: "the scripted-body point verifies the front door's claim-decoded, pre-shaped return", - type: "verifies", - target: "spec:consumers.agent-surface.scripted-context-body", file: "test/self-hosting-consumers.test.ts", constant: "scriptedContextBodyTestAnchor", site: "bindExample(scriptedContextBodyContract", @@ -1260,8 +1092,6 @@ export const expectedAnchors = [ id: "test:protocol.agent-surface.demand-map-entries", nodeType: "Anchor", label: "the demand-map point verifies the three entry points and the absent symbol entry", - type: "verifies", - target: "spec:consumers.agent-surface.demand-map-entries", file: "test/self-hosting-consumers.test.ts", constant: "demandMapEntriesTestAnchor", site: "bindExample(demandMapEntriesContract", @@ -1270,8 +1100,6 @@ export const expectedAnchors = [ id: "test:protocol.reader.concept-entry", nodeType: "Anchor", label: "the concept-entry point verifies the string bridge and the field it names", - type: "verifies", - target: "spec:consumers.reader.concept-entry", file: "test/self-hosting-consumers.test.ts", constant: "conceptEntryTestAnchor", site: "bindExample(conceptEntryContract", @@ -1280,8 +1108,6 @@ export const expectedAnchors = [ id: "test:protocol.reader.file-entry", nodeType: "Anchor", label: "the file-entry point verifies both halves of the file bridge", - type: "verifies", - target: "spec:consumers.reader.file-entry", file: "test/self-hosting-consumers.test.ts", constant: "fileEntryTestAnchor", site: "bindExample(fileEntryContract", @@ -1290,8 +1116,6 @@ export const expectedAnchors = [ id: "test:protocol.reader.changeset-entry", nodeType: "Anchor", label: "the changeset-entry point verifies impact reasons, at-risk edges, and coverage-unknown", - type: "verifies", - target: "spec:consumers.reader.changeset-entry", file: "test/self-hosting-consumers.test.ts", constant: "changesetEntryTestAnchor", site: "bindExample(changesetEntryContract", @@ -1300,208 +1124,181 @@ export const expectedAnchors = [ id: "impl:protocol.sdp-import", nodeType: "CodeNode", label: "plans, refuses, and publishes TypeScript-to-Markdown Spec imports", - type: "satisfies", - target: "spec:carrier.sdp-import", file: "src/cli/import-command.ts", constant: "sdpImportAnchor", site: "export function runImport", + role: "service", }, { id: "impl:protocol.extraction-determinism", nodeType: "CodeNode", label: "repeats and byte-compares graph and contract generation under --check-clean", - type: "satisfies", - target: "spec:extraction.determinism", file: "src/cli/build-command.ts", constant: "determinismAnchor", site: "export function runBuild", + role: "service", }, { id: "impl:protocol.wholesale-view-build-invalidation", nodeType: "CodeNode", label: "invalidates the prior Design Review before every build attempt", - type: "satisfies", - target: "spec:consumers.wholesale-view-rewrite", file: "src/cli/build-command.ts", constant: "wholesaleViewBuildInvalidationAnchor", site: "export function runBuild", + role: "service", }, { id: "impl:protocol.wholesale-view-rewrite", nodeType: "CodeNode", label: "publishes each Design Review as one wholesale temporary-directory replacement", - type: "satisfies", - target: "spec:consumers.wholesale-view-rewrite", file: "src/cli/validate-view-command.ts", constant: "wholesaleViewRewriteAnchor", site: "export function runView", + role: "publisher", }, { id: "impl:protocol.derived-readiness-banner", nodeType: "CodeNode", label: "renders stated readiness beside the structural floor and dishonest divergence", - type: "satisfies", - target: "spec:consumers.derived-readiness-banner", file: "src/projections/design-review-context.ts", - constant: "derivedReadinessBannerAnchor", site: "export function renderReadiness", + role: "renderer", }, { id: "impl:protocol.binding-language-spec-page", nodeType: "CodeNode", label: "renders implementation, verifier, oracle, and observation binding language", - type: "satisfies", - target: "spec:consumers.binding-language-views", file: "src/projections/design-review-context.ts", - constant: "bindingLanguageSpecPageAnchor", site: "export function renderBindings", + role: "renderer", }, { id: "impl:protocol.diagnostic-rendering-design-review", nodeType: "CodeNode", label: "renders structured finding locations in the Design Review table", - type: "satisfies", - target: "spec:validation.diagnostic-rendering", file: "src/projections/design-review-context.ts", - constant: "diagnosticDesignReviewAnchor", site: "export function renderFindings", + role: "renderer", }, { id: "impl:protocol.binding-language-pack-table", nodeType: "CodeNode", label: "renders Pack member implementation and verifier bindings as present or none", - type: "satisfies", - target: "spec:consumers.binding-language-views", file: "src/projections/design-review-pages.ts", - constant: "bindingLanguagePackTableAnchor", site: "export function renderPackPage", + role: "renderer", }, { id: "impl:protocol.binding-language-index-table", nodeType: "CodeNode", label: "renders index implementation and verifier bindings as present or none", - type: "satisfies", - target: "spec:consumers.binding-language-views", file: "src/projections/design-review-pages.ts", - constant: "bindingLanguageIndexTableAnchor", site: "export function renderIndexPage", + role: "renderer", }, { id: "impl:protocol.diagnostic-rendering-cli", nodeType: "CodeNode", label: "composes structured finding fields into the one command-line diagnostic form", - type: "satisfies", - target: "spec:validation.diagnostic-rendering", file: "src/cli/output.ts", constant: "diagnosticCliAnchor", site: "export function formatFinding", + role: "renderer", }, { id: "impl:protocol.kind-evidence", nodeType: "CodeNode", label: "defines the code-level per-kind evidence rows for readiness floors", - type: "satisfies", - target: "spec:validation.kind-evidence", file: "src/validate/readiness-floor.ts", constant: "kindEvidenceAnchor", site: "export const kindEvidence", + role: "contract", }, { id: "impl:protocol.referential-integrity", nodeType: "CodeNode", label: "checks every graph edge endpoint and Pack model reference resolves", - type: "satisfies", - target: "spec:validation.referential-integrity", file: "src/validate/validators.ts", constant: "referentialIntegrityAnchor", site: "function checkEdgeReferentialIntegrity", + role: "validator", }, { id: "impl:protocol.claim-separation", nodeType: "CodeNode", label: "checks graph claims, descriptors, node shapes, and relation endpoint contracts", - type: "satisfies", - target: "spec:validation.claim-separation", file: "src/validate/validators.ts", constant: "claimSeparationAnchor", site: "function checkClaimSeparation", + role: "validator", }, { id: "impl:protocol.verifies-linkage", nodeType: "CodeNode", label: "resolves declared example verification through enabled test bindings", - type: "satisfies", - target: "spec:validation.verification-linkage", file: "src/validate/validators.ts", constant: "verifiesLinkageAnchor", site: "function checkVerifiesLinkage", + role: "validator", }, { id: "impl:protocol.oracle-linkage", nodeType: "CodeNode", label: "resolves and de-duplicates expected-outcome oracle bindings", - type: "satisfies", - target: "spec:validation.verification-linkage", file: "src/validate/validators.ts", constant: "oracleLinkageAnchor", site: "function checkOracleLinkage", + role: "validator", }, { id: "impl:protocol.pack-coherence", nodeType: "CodeNode", label: "checks Pack membership agreement and uniqueness and model-reference kinds", - type: "satisfies", - target: "spec:validation.pack-coherence", file: "src/validate/validators.ts", constant: "packCoherenceAnchor", site: "function checkPackCoherence", + role: "validator", }, { id: "impl:protocol.orphan-signal", nodeType: "CodeNode", label: "surfaces relationless Specs as informative orphan warnings", - type: "satisfies", - target: "spec:validation.warn-level-signals", file: "src/validate/validators.ts", constant: "orphanSignalAnchor", site: "function checkOrphans", + role: "validator", }, { id: "impl:protocol.authored-honesty-shape", nodeType: "CodeNode", label: "refuses delivery facts smuggled through authored section carriers", - type: "satisfies", - target: "spec:validation.authored-honesty", file: "src/validate/validators.ts", constant: "authoringShapeAnchor", site: "function checkAuthoringShape", + role: "validator", }, { id: "impl:protocol.authored-honesty-delivery-facts", nodeType: "CodeNode", label: "checks stated delivery facts equal the one recomputed derivation", - type: "satisfies", - target: "spec:validation.authored-honesty", file: "src/validate/validators.ts", constant: "deliveryFactsHonestyAnchor", site: "function checkDeliveryFacts", + role: "validator", }, { id: "impl:protocol.verifier-gap-signal", nodeType: "CodeNode", label: "surfaces ready Specs without recomputed verifier bindings as informative gaps", - type: "satisfies", - target: "spec:validation.warn-level-signals", file: "src/validate/validators.ts", constant: "gapSignalAnchor", site: "function checkGaps", + role: "validator", }, { id: "oracle:protocol.reader-entry-map", nodeType: "Anchor", label: "expected reader outcome for each graph entry mode", - type: "models", - target: "spec:consumers.reader", file: "test/self-hosting-consumers.oracle.ts", constant: "readerEntryMapOracle", site: "export function expectedReaderOutcome", @@ -1510,18 +1307,15 @@ export const expectedAnchors = [ id: "impl:protocol.oracle-target-eligibility", nodeType: "CodeNode", label: "resolves oracle targets by example-space ownership across Spec kinds", - type: "satisfies", - target: "spec:validation.oracle-target-eligibility", file: "src/graph/oracle-bindings.ts", constant: "oracleTargetEligibilityAnchor", site: "export function isResolvingOracleModel", + role: "decider", }, { id: "test:protocol.oracle-target-eligibility.rule-space-accepted", nodeType: "Anchor", label: "the rule-space point verifies kind-neutral oracle resolution", - type: "verifies", - target: "spec:validation.oracle-target-eligibility.rule-space-accepted", file: "test/self-hosting-validators.test.ts", constant: "ruleSpaceAcceptedTestAnchor", site: "registerRuleSpaceAccepted(", @@ -1530,8 +1324,6 @@ export const expectedAnchors = [ id: "test:protocol.mermaid-view", nodeType: "Anchor", label: "verifies bounded deterministic Mermaid projection and publication", - type: "verifies", - target: "spec:consumers.mermaid-view", file: "test/mermaid-render.test.ts", constant: "mermaidTestAnchor", site: "const checkoutRoot", @@ -1540,8 +1332,6 @@ export const expectedAnchors = [ id: "test:protocol.oracle-target-eligibility.missing-space-refused", nodeType: "Anchor", label: "the missing-space point verifies fail-closed oracle resolution", - type: "verifies", - target: "spec:validation.oracle-target-eligibility.missing-space-refused", file: "test/self-hosting-validators.test.ts", constant: "missingSpaceRefusedTestAnchor", site: "registerMissingSpaceRefused(", @@ -1550,8 +1340,6 @@ export const expectedAnchors = [ id: "oracle:protocol.oracle-target-eligibility", nodeType: "Anchor", label: "expected oracle-linkage result by example-space ownership", - type: "models", - target: "spec:validation.oracle-target-eligibility", file: "test/self-hosting-validators.oracle.ts", constant: "oracleTargetEligibilityOracle", site: "export function expectedOracleTargetEligibilityOutcome", @@ -1560,18 +1348,15 @@ export const expectedAnchors = [ id: "impl:protocol.markdown-pack-authoring", nodeType: "CodeNode", label: "reifies the Markdown Pack manifest into the one carrier path", - type: "satisfies", - target: "spec:carrier.markdown-pack-authoring", file: "src/extract/markdown-pack.ts", constant: "markdownPackAuthoringAnchor", site: "export function reifyMarkdownPack(", + role: "extractor", }, { id: "test:protocol.markdown-pack-authoring.markdown-ts-parity", nodeType: "Anchor", label: "the parity point verifies equal Markdown and TypeScript Pack derivation", - type: "verifies", - target: "spec:carrier.markdown-pack-authoring.markdown-ts-parity", file: "test/self-hosting-pack-markdown.test.ts", constant: "parityTestAnchor", site: "registerMarkdownTsParity(", @@ -1580,8 +1365,6 @@ export const expectedAnchors = [ id: "test:protocol.markdown-pack-authoring.spec-envelope-refused", nodeType: "Anchor", label: "the refusal point verifies the Markdown Pack closed envelope", - type: "verifies", - target: "spec:carrier.markdown-pack-authoring.spec-envelope-refused", file: "test/self-hosting-pack-markdown.test.ts", constant: "refusalTestAnchor", site: "registerSpecEnvelopeRefused(", @@ -1591,48 +1374,42 @@ export const expectedAnchors = [ nodeType: "CodeNode", label: "one query invocation derives the graph once and serves reader, raw graph, and report from that one derivation", - type: "satisfies", - target: "spec:extraction.build-pipeline", file: "src/cli/q-command.ts", constant: "buildPipelineQueryAnchor", site: "export async function runQuery", + role: "service", }, { id: "impl:protocol.build-pipeline-emit", nodeType: "CodeNode", label: "the build command owns the ordered flow through artifact emission", - type: "satisfies", - target: "spec:extraction.build-pipeline", file: "src/cli/build-command.ts", constant: "buildPipelineEmitAnchor", site: "export function runBuild", + role: "service", }, { id: "impl:protocol.example-runner-adapter", nodeType: "CodeNode", label: "registers one test per example and owns the fresh world lifecycle", - type: "satisfies", - target: "spec:extraction.example-runner", file: "src/adapters/vitest.ts", constant: "exampleRunnerAdapterAnchor", site: "export function bindExample", + role: "adapter", }, { id: "impl:protocol.runnable-modules", nodeType: "CodeNode", label: "derives and publishes runnable registrar modules from the graph", - type: "satisfies", - target: "spec:extraction.runnable-modules", file: "src/codegen/contracts.ts", constant: "runnableModulesAnchor", site: "export function generateContracts", + role: "projection", }, { id: "test:protocol.census-page", nodeType: "Anchor", label: "verifies census taxonomy, findings, and structural-binding projection", - type: "verifies", - target: "spec:consumers.census-page", file: "test/census.test.ts", constant: "censusTestAnchor", site: 'describe("the derived census/taxonomy projection"', @@ -1641,8 +1418,6 @@ export const expectedAnchors = [ id: "test:protocol.runnable-modules", nodeType: "Anchor", label: "verifies generated runnable-module registration and runtime semantics", - type: "verifies", - target: "spec:extraction.runnable-modules", file: "test/testing.test.ts", constant: "runnableModulesTestAnchor", site: 'describe("generated registrar testing runtime"', @@ -1651,168 +1426,149 @@ export const expectedAnchors = [ id: "impl:protocol.sdp-import-core", nodeType: "CodeNode", label: "converts a TypeScript-carrier Spec into an idiomatic Markdown twin", - type: "satisfies", - target: "spec:carrier.sdp-import", file: "src/import/import.ts", constant: "sdpImportCoreAnchor", site: "export function importTypeScriptSpec", + role: "service", }, { id: "impl:protocol.sdp-import-markdown-emit", nodeType: "CodeNode", label: "emits the Markdown document twin for an imported Spec", - type: "satisfies", - target: "spec:carrier.sdp-import", file: "src/import/emit-markdown.ts", constant: "sdpImportMarkdownEmitAnchor", site: "export function emitMarkdownSpec", + role: "codec", }, { id: "impl:protocol.example-testing-helpers", nodeType: "CodeNode", label: "adapter-lifecycle and oracle-comparison helpers for bound examples", - type: "satisfies", - target: "spec:extraction.example-runner", file: "src/testing/index.ts", constant: "exampleTestingHelpersAnchor", site: "export function createRunnableExample", + role: "utility", }, { id: "impl:protocol.discover-files", nodeType: "CodeNode", label: "realizes exclusion-aware discovery (normalizeExcludes / discoverFiles)", - type: "satisfies", - target: "spec:extraction.excludes", file: "src/extract/discover.ts", constant: "discoverFilesAnchor", site: "export function normalizeExcludes", + role: "extractor", }, { id: "impl:protocol.protocol-bindings", nodeType: "CodeNode", label: "Protocol builder-import trust (protocolBindingScopeFor / collectProtocolBindings)", - type: "satisfies", - target: "spec:model.anchors", file: "src/extract/protocol-bindings.ts", constant: "protocolBindingsAnchor", site: "export function protocolBindingScopeFor", + role: "extractor", }, { id: "impl:protocol.static-reification", nodeType: "CodeNode", label: "reifies authored carriers from the AST and never evaluates them", - type: "satisfies", - target: "spec:extraction.determinism", file: "src/extract/reify.ts", constant: "staticReificationAnchor", site: "export function reifySourceFile", + role: "extractor", }, { id: "impl:protocol.carrier-reification", nodeType: "CodeNode", label: "reifies each canonical carrier surface into specs, packs, and findings", - type: "satisfies", - target: "spec:extraction.derive-graph", file: "src/extract/carrier.ts", constant: "carrierReificationAnchor", site: "export function reifyTypeScriptCarrier", + role: "extractor", }, { id: "impl:protocol.spec-scaffolder", nodeType: "CodeNode", label: "scaffolds an honest idea-rung Markdown Spec through `sdp new spec`", - type: "satisfies", - target: "spec:consumers.authoring-on-ramp", file: "src/cli/new-spec-command.ts", constant: "specScaffolderAnchor", site: "export function runNewSpec", + role: "service", }, { id: "impl:protocol.delivery-facts", nodeType: "CodeNode", label: "computes delivery facts from resolving binding edges", - type: "satisfies", - target: "spec:extraction.delivery-facts", file: "src/graph/delivery-facts.ts", constant: "deliveryFactsAnchor", site: "export function computeDeliveryFacts", + role: "decider", }, { id: "impl:protocol.example-space", nodeType: "CodeNode", label: "resolves example-space vocabulary for contracts and floors", - type: "satisfies", - target: "spec:extraction.executable-contracts", file: "src/graph/example-space.ts", constant: "exampleSpaceAnchor", site: "export function resolveExampleVocabulary", + role: "decider", }, { id: "impl:protocol.graph-index", nodeType: "CodeNode", label: "builds the one-graph index validators and the reader share", - type: "satisfies", - target: "spec:validation.two-check-families", file: "src/validate/graph-index.ts", constant: "graphIndexAnchor", site: "export function buildGraphIndex", + role: "utility", }, { id: "impl:protocol.validation-contracts", nodeType: "CodeNode", label: "conformance/honesty family currency and finding shapes", - type: "satisfies", - target: "spec:validation.two-check-families", file: "src/validate/contracts.ts", constant: "validationContractsAnchor", site: "export const validatorFamilies", + role: "contract", }, { id: "impl:protocol.agent-surface-cli", nodeType: "CodeNode", label: "CLI verb dispatcher and agent-surface front of the package CLI", - type: "satisfies", - target: "spec:consumers.agent-surface", file: "src/cli/sdp.ts", - constant: "agentSurfaceCliAnchor", site: "export function runSdpCli", + role: "service", }, { id: "impl:protocol.census-page-cli", nodeType: "CodeNode", label: "publishes the census through the explicit `sdp census` surface", - type: "satisfies", - target: "spec:consumers.census-page", file: "src/cli/census-command.ts", constant: "censusPageCliAnchor", site: "export function runCensus", + role: "publisher", }, { id: "impl:protocol.mermaid-view-cli", nodeType: "CodeNode", label: "publishes Mermaid through the explicit `sdp mermaid` surface", - type: "satisfies", - target: "spec:consumers.mermaid-view", file: "src/cli/mermaid-command.ts", constant: "mermaidViewCliAnchor", site: "export function runMermaid", + role: "publisher", }, { id: "impl:protocol.gherkin-view-cli", nodeType: "CodeNode", label: "publishes the Gherkin view through the explicit `sdp gherkin` surface", - type: "satisfies", - target: "spec:consumers.gherkin-view", file: "src/cli/gherkin-command.ts", constant: "gherkinViewCliAnchor", site: "export function runGherkinView", + role: "publisher", }, { id: "test:protocol.markdown-body-grammar.table-refused", nodeType: "Anchor", label: "the table point verifies the block-structure refusal", - type: "verifies", - target: "spec:carrier.markdown-body-grammar.table-refused", file: "test/self-hosting-markdown-grammar.test.ts", constant: "tableRefusedTestAnchor", site: "registerTableRefused(", @@ -1821,8 +1577,6 @@ export const expectedAnchors = [ id: "test:protocol.markdown-body-grammar.continuation-line-refused", nodeType: "Anchor", label: "the continuation-line point verifies the one-line entry rule", - type: "verifies", - target: "spec:carrier.markdown-body-grammar.continuation-line-refused", file: "test/self-hosting-markdown-grammar.test.ts", constant: "continuationLineRefusedTestAnchor", site: "registerContinuationLineRefused(", @@ -1831,8 +1585,6 @@ export const expectedAnchors = [ id: "test:protocol.markdown-body-grammar.ordered-list-refused", nodeType: "Anchor", label: "the ordered-list point verifies the list-entry rule", - type: "verifies", - target: "spec:carrier.markdown-body-grammar.ordered-list-refused", file: "test/self-hosting-markdown-grammar.test.ts", constant: "orderedListRefusedTestAnchor", site: "registerOrderedListRefused(", @@ -1841,8 +1593,6 @@ export const expectedAnchors = [ id: "test:protocol.markdown-body-grammar.foreign-fence-refused", nodeType: "Anchor", label: "the foreign-fence point verifies the closed fence set", - type: "verifies", - target: "spec:carrier.markdown-body-grammar.foreign-fence-refused", file: "test/self-hosting-markdown-grammar.test.ts", constant: "foreignFenceRefusedTestAnchor", site: "registerForeignFenceRefused(", @@ -1851,8 +1601,6 @@ export const expectedAnchors = [ id: "test:protocol.markdown-body-grammar.h3-refused", nodeType: "Anchor", label: "the H3 point verifies the single-H3 rule", - type: "verifies", - target: "spec:carrier.markdown-body-grammar.h3-refused", file: "test/self-hosting-markdown-grammar.test.ts", constant: "h3RefusedTestAnchor", site: "registerH3Refused(", @@ -1861,8 +1609,6 @@ export const expectedAnchors = [ id: "test:protocol.markdown-body-grammar.plain-bullet-refused", nodeType: "Anchor", label: "the plain-bullet point verifies the open-section key rule", - type: "verifies", - target: "spec:carrier.markdown-body-grammar.plain-bullet-refused", file: "test/self-hosting-markdown-grammar.test.ts", constant: "plainBulletRefusedTestAnchor", site: "registerPlainBulletRefused(", @@ -1871,8 +1617,6 @@ export const expectedAnchors = [ id: "test:protocol.markdown-body-grammar.keyed-bullet-refused", nodeType: "Anchor", label: "the keyed-bullet point verifies the keyed-bullet rule", - type: "verifies", - target: "spec:carrier.markdown-body-grammar.keyed-bullet-refused", file: "test/self-hosting-markdown-grammar.test.ts", constant: "keyedBulletRefusedTestAnchor", site: "registerKeyedBulletRefused(", @@ -1881,8 +1625,6 @@ export const expectedAnchors = [ id: "test:protocol.markdown-body-grammar.trailing-prose-refused", nodeType: "Anchor", label: "the trailing-prose point verifies the prose-ownership rule", - type: "verifies", - target: "spec:carrier.markdown-body-grammar.trailing-prose-refused", file: "test/self-hosting-markdown-grammar.test.ts", constant: "trailingProseRefusedTestAnchor", site: "registerTrailingProseRefused(", @@ -1891,8 +1633,6 @@ export const expectedAnchors = [ id: "test:protocol.markdown-body-grammar.repeated-field-refused", nodeType: "Anchor", label: "the repeated-field point verifies the at-most-once rule", - type: "verifies", - target: "spec:carrier.markdown-body-grammar.repeated-field-refused", file: "test/self-hosting-markdown-grammar.test.ts", constant: "repeatedFieldRefusedTestAnchor", site: "registerRepeatedFieldRefused(", @@ -1901,8 +1641,6 @@ export const expectedAnchors = [ id: "test:protocol.markdown-body-grammar.unrecognized-heading-refused", nodeType: "Anchor", label: "the unrecognized-heading point verifies the closed owner set", - type: "verifies", - target: "spec:carrier.markdown-body-grammar.unrecognized-heading-refused", file: "test/self-hosting-markdown-grammar.test.ts", constant: "unrecognizedHeadingRefusedTestAnchor", site: "registerUnrecognizedHeadingRefused(", @@ -1911,8 +1649,6 @@ export const expectedAnchors = [ id: "test:protocol.markdown-body-grammar.second-primary-owner-refused", nodeType: "Anchor", label: "the second-primary-owner point verifies the one-primary-owner rule", - type: "verifies", - target: "spec:carrier.markdown-body-grammar.second-primary-owner-refused", file: "test/self-hosting-markdown-grammar.test.ts", constant: "secondPrimaryOwnerRefusedTestAnchor", site: "registerSecondPrimaryOwnerRefused(", @@ -1921,8 +1657,6 @@ export const expectedAnchors = [ id: "test:protocol.markdown-body-grammar.raw-html-refused", nodeType: "Anchor", label: "the raw-HTML point verifies the raw-HTML refusal outside code spans", - type: "verifies", - target: "spec:carrier.markdown-body-grammar.raw-html-refused", file: "test/self-hosting-markdown-grammar.test.ts", constant: "rawHtmlRefusedTestAnchor", site: "registerRawHtmlRefused(", @@ -1931,8 +1665,6 @@ export const expectedAnchors = [ id: "test:protocol.markdown-body-grammar.shared-description-refused", nodeType: "Anchor", label: "the shared-description point verifies the one-description-owner rule", - type: "verifies", - target: "spec:carrier.markdown-body-grammar.shared-description-refused", file: "test/self-hosting-markdown-grammar.test.ts", constant: "sharedDescriptionRefusedTestAnchor", site: "registerSharedDescriptionRefused(", @@ -1941,8 +1673,6 @@ export const expectedAnchors = [ id: "test:protocol.markdown-body-grammar.integer-term-refused", nodeType: "Anchor", label: "the integer-term point verifies the integer-like Model term refusal", - type: "verifies", - target: "spec:carrier.markdown-body-grammar.integer-term-refused", file: "test/self-hosting-markdown-grammar.test.ts", constant: "integerTermRefusedTestAnchor", site: "registerIntegerTermRefused(", @@ -1951,28 +1681,24 @@ export const expectedAnchors = [ id: "impl:protocol.markdown-body-grammar", nodeType: "CodeNode", label: "enforces the closed Markdown body grammar", - type: "satisfies", - target: "spec:carrier.markdown-body-grammar", file: "src/extract/markdown-body.ts", constant: "markdownBodyGrammarAnchor", site: "export function parseMarkdownBody", + role: "codec", }, { id: "impl:protocol.inline-code-spans", nodeType: "CodeNode", label: "scans raw HTML outside matched code spans", - type: "satisfies", - target: "spec:carrier.inline-code-spans", file: "src/extract/markdown-inline-code.ts", constant: "inlineCodeSpansAnchor", site: "export function hasRawHtmlOutsideCodeSpans", + role: "codec", }, { id: "oracle:protocol.inline-code-spans", nodeType: "Anchor", label: "expected inline code outcome by authored location", - type: "models", - target: "spec:carrier.inline-code-spans", file: "test/self-hosting-inline-code.test.ts", constant: "inlineCodeOracleAnchor", site: "function expectedInlineCodeOutcome", @@ -1981,8 +1707,6 @@ export const expectedAnchors = [ id: "test:protocol.inline-code-spans.narrative", nodeType: "Anchor", label: "verifies inline code in Spec narrative", - type: "verifies", - target: "spec:carrier.inline-code-spans.narrative", file: "test/self-hosting-inline-code.test.ts", constant: "narrativeTestAnchor", site: "registerNarrative(", @@ -1991,8 +1715,6 @@ export const expectedAnchors = [ id: "test:protocol.inline-code-spans.description", nodeType: "Anchor", label: "verifies inline code in section descriptions", - type: "verifies", - target: "spec:carrier.inline-code-spans.description", file: "test/self-hosting-inline-code.test.ts", constant: "descriptionTestAnchor", site: "registerDescription(", @@ -2001,8 +1723,6 @@ export const expectedAnchors = [ id: "test:protocol.inline-code-spans.list", nodeType: "Anchor", label: "verifies inline code in list entries", - type: "verifies", - target: "spec:carrier.inline-code-spans.list", file: "test/self-hosting-inline-code.test.ts", constant: "listTestAnchor", site: "registerList(", @@ -2011,8 +1731,6 @@ export const expectedAnchors = [ id: "test:protocol.inline-code-spans.pack", nodeType: "Anchor", label: "verifies inline code in Pack framing", - type: "verifies", - target: "spec:carrier.inline-code-spans.pack", file: "test/self-hosting-inline-code.test.ts", constant: "packTestAnchor", site: "registerPack(", @@ -2021,18 +1739,15 @@ export const expectedAnchors = [ id: "impl:protocol.unbound-example-posture", nodeType: "CodeNode", label: "limits unbound example warnings to stated ready", - type: "satisfies", - target: "spec:validation.unbound-example-posture", file: "src/validate/validators.ts", constant: "unboundExamplePostureAnchor", site: "function checkVerifiesLinkage", + role: "validator", }, { id: "oracle:protocol.unbound-example-posture", nodeType: "Anchor", label: "expected verification signals by posture matrix", - type: "models", - target: "spec:validation.unbound-example-posture", file: "test/self-hosting-unbound-example.test.ts", constant: "unboundPostureOracleAnchor", site: "function expectedPosture", @@ -2041,8 +1756,6 @@ export const expectedAnchors = [ id: "test:protocol.unbound-example-posture.lower-rungs", nodeType: "Anchor", label: "verifies declared-only examples below ready", - type: "verifies", - target: "spec:validation.unbound-example-posture.lower-rungs", file: "test/self-hosting-unbound-example.test.ts", constant: "lowerRungsTestAnchor", site: "registerLowerRungs(", @@ -2051,8 +1764,6 @@ export const expectedAnchors = [ id: "test:protocol.unbound-example-posture.warning-cases", nodeType: "Anchor", label: "verifies ready and wrong-kind linkage warnings", - type: "verifies", - target: "spec:validation.unbound-example-posture.warning-cases", file: "test/self-hosting-unbound-example.test.ts", constant: "warningCasesTestAnchor", site: "registerWarningCases(", @@ -2061,8 +1772,6 @@ export const expectedAnchors = [ id: "test:protocol.unbound-example-posture.parent-traces", nodeType: "Anchor", label: "verifies parent gaps and unchanged oracle signals", - type: "verifies", - target: "spec:validation.unbound-example-posture.parent-traces", file: "test/self-hosting-unbound-example.test.ts", constant: "parentTracesTestAnchor", site: "registerParentTraces(", @@ -2071,18 +1780,15 @@ export const expectedAnchors = [ id: "impl:protocol.typed-dependency-floor", nodeType: "CodeNode", label: "checks stated readiness across all four typed dependencies", - type: "satisfies", - target: "spec:validation.typed-dependency-floor", file: "src/validate/readiness-floor.ts", constant: "typedDependencyFloorAnchor", site: "function typedDependencyTargetsAreDefined", + role: "validator", }, { id: "oracle:protocol.typed-dependency-floor", nodeType: "Anchor", label: "expected readiness failures by typed dependency matrix", - type: "models", - target: "spec:validation.typed-dependency-floor", file: "test/self-hosting-typed-dependency.test.ts", constant: "typedDependencyOracleAnchor", site: "function expectedFloor", @@ -2091,8 +1797,6 @@ export const expectedAnchors = [ id: "test:protocol.typed-dependency-floor.relation-rungs", nodeType: "Anchor", label: "verifies six relations across four target rungs", - type: "verifies", - target: "spec:validation.typed-dependency-floor.relation-rungs", file: "test/self-hosting-typed-dependency.test.ts", constant: "relationRungsTestAnchor", site: "registerRelationRungs(", @@ -2101,8 +1805,6 @@ export const expectedAnchors = [ id: "test:protocol.typed-dependency-floor.missing-targets", nodeType: "Anchor", label: "verifies unresolved targets fail resolution once", - type: "verifies", - target: "spec:validation.typed-dependency-floor.missing-targets", file: "test/self-hosting-typed-dependency.test.ts", constant: "missingTargetsTestAnchor", site: "registerMissingTargets(", @@ -2111,8 +1813,6 @@ export const expectedAnchors = [ id: "test:protocol.typed-dependency-floor.unsettled-fact", nodeType: "Anchor", label: "verifies blocking questions and bounded readiness", - type: "verifies", - target: "spec:validation.typed-dependency-floor.unsettled-fact", file: "test/self-hosting-typed-dependency.test.ts", constant: "unsettledFactTestAnchor", site: "registerUnsettledFact(", @@ -2121,8 +1821,6 @@ export const expectedAnchors = [ id: "test:protocol.typed-dependency-floor.stated-readiness", nodeType: "Anchor", label: "verifies stated target readiness and kind blindness", - type: "verifies", - target: "spec:validation.typed-dependency-floor.stated-readiness", file: "test/self-hosting-typed-dependency.test.ts", constant: "statedReadinessTestAnchor", site: "registerStatedReadiness(", @@ -2131,8 +1829,6 @@ export const expectedAnchors = [ id: "impl:protocol.register-recipes", nodeType: "CodeNode", label: "asserts realization of the shipped register recipes", - type: "satisfies", - target: "spec:consumers.agent-surface.register-recipes", file: "test/recipes.test.ts", constant: "registerRecipesImplementationAnchor", site: 'describe("the agent-surface recipe corpus",', @@ -2141,8 +1837,6 @@ export const expectedAnchors = [ id: "test:protocol.register-recipes", nodeType: "Anchor", label: "recipe checks verify the register recipes", - type: "verifies", - target: "spec:consumers.agent-surface.register-recipes", file: "test/recipes.test.ts", constant: "registerRecipesTestAnchor", site: 'describe("the agent-surface recipe corpus",', @@ -2151,8 +1845,6 @@ export const expectedAnchors = [ id: "impl:protocol.adopter-on-ramp", nodeType: "CodeNode", label: "asserts realization of the shipped adopter teaching in the authoring skill", - type: "satisfies", - target: "spec:consumers.adopter-on-ramp", file: "test/skills.test.ts", constant: "adopterOnRampImplementationAnchor", site: 'describe("adopter teaching",', @@ -2161,8 +1853,6 @@ export const expectedAnchors = [ id: "test:protocol.adopter-on-ramp", nodeType: "Anchor", label: "skill-asset checks verify the adopter on-ramp", - type: "verifies", - target: "spec:consumers.adopter-on-ramp", file: "test/skills.test.ts", constant: "adopterOnRampTestAnchor", site: 'describe("adopter teaching",', @@ -2171,8 +1861,6 @@ export const expectedAnchors = [ id: "impl:protocol.shipped-protocol-corpus", nodeType: "CodeNode", label: "asserts the published package ships the Protocol corpus", - type: "satisfies", - target: "spec:consumers.shipped-protocol-corpus", file: "test/package-smoke.test.ts", constant: "shippedProtocolCorpusImplementationAnchor", site: 'describe("published package surface",', @@ -2181,8 +1869,6 @@ export const expectedAnchors = [ id: "test:protocol.shipped-protocol-corpus", nodeType: "Anchor", label: "package smoke checks verify the shipped corpus", - type: "verifies", - target: "spec:consumers.shipped-protocol-corpus", file: "test/package-smoke.test.ts", constant: "shippedProtocolCorpusTestAnchor", site: 'describe("published package surface",', @@ -2191,18 +1877,15 @@ export const expectedAnchors = [ id: "impl:protocol.prose-mentions", nodeType: "CodeNode", label: "checks that prose mentions resolve and warns once per unbacked Spec pair", - type: "satisfies", - target: "spec:validation.prose-mentions", file: "src/validate/validators.ts", constant: "proseMentionsAnchor", site: "function checkProseMentions", + role: "validator", }, { id: "test:protocol.prose-mentions", nodeType: "Anchor", label: "verifies prose-mention resolution and the unbacked-pair warning", - type: "verifies", - target: "spec:validation.prose-mentions", file: "test/validators.test.ts", constant: "proseMentionsTestAnchor", site: 'describe("conformance/prose-mentions', @@ -2211,39 +1894,34 @@ export const expectedAnchors = [ id: "impl:protocol.pack-member-order", nodeType: "CodeNode", label: "keeps manifest member order on the Pack node", - type: "satisfies", - target: "spec:extraction.pack-member-order", file: "src/extract/derive.ts", constant: "packMemberOrderAnchor", site: "function derivePackNode", + role: "extractor", }, { id: "impl:protocol.open-question-keys", nodeType: "CodeNode", label: "reads an open question's optional key from its Markdown marker and refuses a bad or repeated key", - type: "satisfies", - target: "spec:model.open-question-keys", file: "src/extract/markdown-body-owner-behavior.ts", constant: "openQuestionKeysAnchor", site: "const OPEN_QUESTION_MARKER", + role: "codec", }, { id: "impl:protocol.next-rung-floor", nodeType: "CodeNode", label: "evaluates the floor for a target rung and names the targets that break a dependency", - type: "satisfies", - target: "spec:validation.next-rung-floor", file: "src/validate/readiness-floor.ts", constant: "nextRungFloorAnchor", site: "export function evaluateReadinessFloor", + role: "validator", }, { id: "impl:protocol.address-and-cycle-recipes", nodeType: "CodeNode", label: "asserts realization of the shipped address resolution and dependency cycle recipes", - type: "satisfies", - target: "spec:consumers.agent-surface.address-and-cycle-recipes", file: "test/recipes.test.ts", constant: "addressAndCycleRecipesImplementationAnchor", site: 'describe("the agent-surface recipe corpus"', @@ -2252,8 +1930,6 @@ export const expectedAnchors = [ id: "test:protocol.address-and-cycle-recipes", nodeType: "Anchor", label: "recipe checks verify the address resolution and dependency cycle recipes", - type: "verifies", - target: "spec:consumers.agent-surface.address-and-cycle-recipes", file: "test/recipes.test.ts", constant: "addressAndCycleRecipesTestAnchor", site: 'describe("the agent-surface recipe corpus"', @@ -2262,8 +1938,6 @@ export const expectedAnchors = [ id: "oracle:protocol.next-rung-floor", nodeType: "Anchor", label: "expected next-rung clause and targets by structure", - type: "models", - target: "spec:validation.next-rung-floor", file: "test/self-hosting-next-rung-floor.test.ts", constant: "nextRungOracleAnchor", site: "function answerFor", @@ -2272,8 +1946,6 @@ export const expectedAnchors = [ id: "test:protocol.next-rung-floor.typed-dependency-targets", nodeType: "Anchor", label: "verifies the next rung's failure names the dependency stated below defined", - type: "verifies", - target: "spec:validation.next-rung-floor.typed-dependency-targets", file: "test/self-hosting-next-rung-floor.test.ts", constant: "typedDependencyTargetsTestAnchor", site: "registerTypedDependencyTargets(", @@ -2282,8 +1954,6 @@ export const expectedAnchors = [ id: "test:protocol.next-rung-floor.blocking-question", nodeType: "Anchor", label: "verifies the next rung's failure names a blocking question and no target", - type: "verifies", - target: "spec:validation.next-rung-floor.blocking-question", file: "test/self-hosting-next-rung-floor.test.ts", constant: "blockingQuestionTestAnchor", site: "registerBlockingQuestion(", @@ -2292,8 +1962,6 @@ export const expectedAnchors = [ id: "oracle:protocol.open-question-keys", nodeType: "Anchor", label: "expected reification, question keys and mention errors by the key rules", - type: "models", - target: "spec:model.open-question-keys", file: "test/self-hosting-open-question-keys.test.ts", constant: "openQuestionKeysOracleAnchor", site: "function answerFor", @@ -2302,8 +1970,6 @@ export const expectedAnchors = [ id: "test:protocol.open-question-keys.keyed-question-resolves", nodeType: "Anchor", label: "verifies a keyed question is reified and its question address resolves", - type: "verifies", - target: "spec:model.open-question-keys.keyed-question-resolves", file: "test/self-hosting-open-question-keys.test.ts", constant: "keyedQuestionResolvesTestAnchor", site: "registerKeyedQuestionResolves(", @@ -2312,8 +1978,6 @@ export const expectedAnchors = [ id: "test:protocol.open-question-keys.renamed-key-breaks", nodeType: "Anchor", label: "verifies a renamed question key fails the mention of its old address", - type: "verifies", - target: "spec:model.open-question-keys.renamed-key-breaks", file: "test/self-hosting-open-question-keys.test.ts", constant: "renamedKeyBreaksTestAnchor", site: "registerRenamedKeyBreaks(", @@ -2322,8 +1986,6 @@ export const expectedAnchors = [ id: "test:protocol.open-question-keys.repeated-key-refused", nodeType: "Anchor", label: "verifies a repeated question key refuses its carrier whole", - type: "verifies", - target: "spec:model.open-question-keys.repeated-key-refused", file: "test/self-hosting-open-question-keys.test.ts", constant: "repeatedKeyRefusedTestAnchor", site: "registerRepeatedKeyRefused(", @@ -2332,10 +1994,692 @@ export const expectedAnchors = [ id: "test:protocol.pack-member-order", nodeType: "Anchor", label: "verifies manifest order in the graph, reader and Pack page", - type: "verifies", - target: "spec:extraction.pack-member-order.manifest-order-kept", file: "test/self-hosting-pack-member-order.test.ts", constant: "packMemberOrderTestAnchor", site: "registerManifestOrderKept(", }, ] as const; + +// re-measured under plan 40 +export const expectedBindingEdges = [ + ["test:protocol.open-section-order", "verifies", "spec:extraction.open-section-order"], + ["impl:protocol.open-section-order", "satisfies", "spec:extraction.open-section-order"], + ["component:protocol.model", "satisfies", "spec:model.core-model"], + ["component:protocol.extract", "satisfies", "spec:extraction.derive-graph"], + ["component:protocol.graph", "satisfies", "spec:extraction.claim-taxonomy"], + ["component:protocol.validate", "satisfies", "spec:validation.two-check-families"], + ["component:protocol.reader", "satisfies", "spec:consumers.reader"], + ["component:protocol.projections", "satisfies", "spec:consumers.projections-model"], + ["component:protocol.cli", "satisfies", "spec:extraction.build-pipeline"], + ["component:protocol.runner", "satisfies", "spec:extraction.example-runner"], + ["component:protocol.codegen", "satisfies", "spec:extraction.runnable-modules"], + ["component:protocol.notation", "satisfies", "spec:carrier.slot-notation"], + ["component:protocol.adapters", "satisfies", "spec:extraction.example-runner"], + ["component:protocol.import", "satisfies", "spec:carrier.sdp-import"], + ["component:protocol.testing", "satisfies", "spec:extraction.example-runner"], + ["impl:protocol.extract", "satisfies", "spec:extraction.derive-graph"], + ["impl:protocol.derive-graph", "satisfies", "spec:extraction.derive-graph"], + ["test:protocol.extract", "verifies", "spec:extraction.derive-graph"], + ["test:protocol.delivery-facts", "verifies", "spec:extraction.delivery-facts"], + ["test:protocol.structural-self-binding", "verifies", "spec:protocol.structural-self-binding"], + ["test:protocol.extraction-determinism", "verifies", "spec:extraction.determinism"], + [ + "test:protocol.build-pipeline.same-invocation", + "verifies", + "spec:extraction.build-pipeline.same-invocation", + ], + ["impl:protocol.authoring-on-ramp", "satisfies", "spec:consumers.authoring-on-ramp"], + [ + "impl:protocol.delivery-session-on-ramp", + "satisfies", + "spec:consumers.delivery-session-on-ramp", + ], + [ + "impl:protocol.authoring-recipes", + "satisfies", + "spec:consumers.agent-surface.authoring-recipes", + ], + ["test:protocol.authoring-on-ramp", "verifies", "spec:consumers.authoring-on-ramp"], + ["test:protocol.delivery-session-on-ramp", "verifies", "spec:consumers.delivery-session-on-ramp"], + ["test:protocol.authoring-recipes", "verifies", "spec:consumers.agent-surface.authoring-recipes"], + ["impl:protocol.readiness-floor", "satisfies", "spec:validation.readiness-floor"], + ["test:protocol.readiness-floor", "verifies", "spec:validation.readiness-floor"], + ["impl:protocol.markdown-authoring", "satisfies", "spec:carrier.markdown-authoring"], + ["impl:protocol.gherkin-authoring", "satisfies", "spec:carrier.gherkin-authoring"], + ["impl:protocol.markdown-parser", "satisfies", "spec:carrier.markdown-parser"], + ["test:protocol.markdown-parser", "verifies", "spec:carrier.markdown-parser"], + ["impl:protocol.envelope-contract", "satisfies", "spec:carrier.envelope-contract"], + ["test:protocol.envelope-contract", "verifies", "spec:carrier.envelope-contract"], + ["impl:protocol.prose-ownership", "satisfies", "spec:carrier.prose-ownership-rule"], + ["test:protocol.prose-ownership", "verifies", "spec:carrier.prose-ownership-rule"], + ["impl:protocol.duplicate-id-exclusion", "satisfies", "spec:validation.duplicate-ids"], + [ + "test:protocol.duplicate-ids.dual-carrier", + "verifies", + "spec:validation.duplicate-ids.dual-carrier", + ], + [ + "test:protocol.warn-level-signals.orphan-signal", + "verifies", + "spec:validation.warn-level-signals.orphan-signal", + ], + [ + "test:protocol.warn-level-signals.ready-gap-signal", + "verifies", + "spec:validation.warn-level-signals.ready-gap-signal", + ], + [ + "test:protocol.readiness-floor.unrelated-scoped-spec", + "verifies", + "spec:validation.readiness-floor.unrelated-scoped-spec", + ], + [ + "test:protocol.readiness-floor.blocking-open-question", + "verifies", + "spec:validation.readiness-floor.blocking-open-question", + ], + [ + "test:protocol.kind-evidence.constraints-alone", + "verifies", + "spec:validation.kind-evidence.constraints-alone", + ], + [ + "test:protocol.kind-evidence.untargeted-constraint", + "verifies", + "spec:validation.kind-evidence.untargeted-constraint", + ], + [ + "test:protocol.kind-evidence.empty-promoted-child", + "verifies", + "spec:validation.kind-evidence.empty-promoted-child", + ], + [ + "test:protocol.referential-integrity.dangling-target", + "verifies", + "spec:validation.referential-integrity.dangling-target", + ], + [ + "test:protocol.referential-integrity.did-you-mean", + "verifies", + "spec:validation.referential-integrity.did-you-mean", + ], + [ + "test:protocol.authored-honesty.section-authored-fact", + "verifies", + "spec:validation.authored-honesty.section-authored-fact", + ], + [ + "test:protocol.authored-honesty.unearned-stated-fact", + "verifies", + "spec:validation.authored-honesty.unearned-stated-fact", + ], + [ + "test:protocol.claim-separation.collapsed-edge-claim", + "verifies", + "spec:validation.claim-separation.collapsed-edge-claim", + ], + [ + "test:protocol.claim-separation.unratified-descriptor", + "verifies", + "spec:validation.claim-separation.unratified-descriptor", + ], + [ + "test:protocol.verification-linkage.unbound-example", + "verifies", + "spec:validation.verification-linkage.unbound-example", + ], + [ + "test:protocol.verification-linkage.unresolved-oracle", + "verifies", + "spec:validation.verification-linkage.unresolved-oracle", + ], + [ + "test:protocol.pack-coherence.incoherent-aggregate", + "verifies", + "spec:validation.pack-coherence.incoherent-aggregate", + ], + [ + "test:protocol.excludes.segment-boundary", + "verifies", + "spec:extraction.excludes.segment-boundary", + ], + ["test:protocol.excludes.refused-path", "verifies", "spec:extraction.excludes.refused-path"], + [ + "test:protocol.schema-versioning.declared-version", + "verifies", + "spec:extraction.schema-versioning.declared-version", + ], + [ + "test:protocol.stable-ids.namespaced-round-trip", + "verifies", + "spec:model.stable-ids.namespaced-round-trip", + ], + [ + "test:protocol.stable-ids.malformed-refusal", + "verifies", + "spec:model.stable-ids.malformed-refusal", + ], + [ + "test:protocol.stable-ids.unsectioned-address-refused", + "verifies", + "spec:model.stable-ids.unsectioned-address-refused", + ], + [ + "test:protocol.markdown-parser.bounded-parity", + "verifies", + "spec:carrier.markdown-parser.bounded-parity", + ], + [ + "test:protocol.gherkin-authoring.authored-fact-refused", + "verifies", + "spec:carrier.gherkin-authoring.authored-fact-refused", + ], + [ + "test:protocol.gherkin-authoring.contract-parity", + "verifies", + "spec:carrier.gherkin-authoring.contract-parity", + ], + [ + "test:protocol.gherkin-authoring.duplicate-surface-refused", + "verifies", + "spec:carrier.gherkin-authoring.duplicate-surface-refused", + ], + [ + "test:protocol.gherkin-authoring.example-space-extraction", + "verifies", + "spec:carrier.gherkin-authoring.example-space-extraction", + ], + [ + "test:protocol.gherkin-authoring.malformed-relation-refused", + "verifies", + "spec:carrier.gherkin-authoring.malformed-relation-refused", + ], + [ + "test:protocol.gherkin-authoring.missing-id-refused", + "verifies", + "spec:carrier.gherkin-authoring.missing-id-refused", + ], + [ + "test:protocol.gherkin-authoring.parent-child-extraction", + "verifies", + "spec:carrier.gherkin-authoring.parent-child-extraction", + ], + [ + "test:protocol.gherkin-authoring.unbound-ready-refused", + "verifies", + "spec:carrier.gherkin-authoring.unbound-ready-refused", + ], + [ + "test:protocol.gherkin-authoring.unknown-tag-refused", + "verifies", + "spec:carrier.gherkin-authoring.unknown-tag-refused", + ], + [ + "test:protocol.gherkin-authoring.unsupported-construct-refused", + "verifies", + "spec:carrier.gherkin-authoring.unsupported-construct-refused", + ], + [ + "test:protocol.gherkin-authoring.description-location-refused", + "verifies", + "spec:carrier.gherkin-authoring.description-location-refused", + ], + [ + "test:protocol.gherkin-authoring.step-less-scenario-refused", + "verifies", + "spec:carrier.gherkin-authoring.step-less-scenario-refused", + ], + [ + "test:protocol.gherkin-authoring.multi-finding-bounded", + "verifies", + "spec:carrier.gherkin-authoring.multi-finding-bounded", + ], + ["test:protocol.sdp-import.round-trip", "verifies", "spec:carrier.sdp-import.round-trip"], + ["impl:protocol.anchor-extraction", "satisfies", "spec:model.anchors"], + ["impl:protocol.anchor-model", "satisfies", "spec:model.anchors"], + ["impl:protocol.pack-aggregate", "satisfies", "spec:model.pack-aggregate"], + ["impl:protocol.spec-descriptors", "satisfies", "spec:model.core-model"], + ["impl:protocol.spec-primitive", "satisfies", "spec:model.core-model"], + ["impl:protocol.spec-relations", "satisfies", "spec:model.relations"], + ["impl:protocol.spec-sections", "satisfies", "spec:model.spec-sections"], + ["impl:protocol.stable-ids", "satisfies", "spec:model.stable-ids"], + ["impl:protocol.verifier-semantics", "satisfies", "spec:model.spec-sections"], + ["impl:protocol.validation-families", "satisfies", "spec:validation.two-check-families"], + ["impl:protocol.projections-model", "satisfies", "spec:consumers.projections-model"], + ["impl:protocol.agent-surface", "satisfies", "spec:consumers.agent-surface"], + ["impl:protocol.reader-impact", "satisfies", "spec:consumers.reader"], + ["impl:protocol.reader", "satisfies", "spec:consumers.reader"], + ["impl:protocol.design-review", "satisfies", "spec:consumers.design-review"], + ["impl:protocol.census-page", "satisfies", "spec:consumers.census-page"], + ["impl:protocol.mermaid-view", "satisfies", "spec:consumers.mermaid-view"], + ["impl:protocol.gherkin-view", "satisfies", "spec:consumers.gherkin-view"], + ["test:protocol.gherkin-view", "verifies", "spec:consumers.gherkin-view"], + ["impl:protocol.exclusion-surface", "satisfies", "spec:extraction.excludes"], + ["impl:protocol.graph-claims", "satisfies", "spec:extraction.claim-taxonomy"], + ["impl:protocol.regenerability", "satisfies", "spec:extraction.regenerability"], + ["impl:protocol.schema-version", "satisfies", "spec:extraction.schema-versioning"], + ["impl:protocol.executable-contracts", "satisfies", "spec:extraction.executable-contracts"], + ["impl:protocol.example-runner", "satisfies", "spec:extraction.example-runner"], + ["impl:protocol.slot-notation", "satisfies", "spec:carrier.slot-notation"], + [ + "test:protocol.executable-contracts.concreteness-refusal", + "verifies", + "spec:extraction.executable-contracts.concreteness-refusal", + ], + [ + "test:protocol.executable-contracts.multi-entry-example", + "verifies", + "spec:extraction.executable-contracts.multi-entry-example", + ], + [ + "test:protocol.executable-contracts.case-colliding-path", + "verifies", + "spec:extraction.executable-contracts.case-colliding-path", + ], + [ + "test:protocol.example-runner.step-order", + "verifies", + "spec:extraction.example-runner.step-order", + ], + [ + "test:protocol.example-runner.red-step-naming", + "verifies", + "spec:extraction.example-runner.red-step-naming", + ], + [ + "test:protocol.slot-notation.typed-declaration", + "verifies", + "spec:carrier.slot-notation.typed-declaration", + ], + [ + "test:protocol.slot-notation.refused-guess", + "verifies", + "spec:carrier.slot-notation.refused-guess", + ], + ["test:protocol.anchors.lookalike-refusal", "verifies", "spec:model.anchors.lookalike-refusal"], + ["test:protocol.anchors.physical-identity", "verifies", "spec:model.anchors.physical-identity"], + [ + "test:protocol.two-check-families.split-report", + "verifies", + "spec:validation.two-check-families.split-report", + ], + [ + "test:protocol.derived-readiness-banner.dishonest-divergence", + "verifies", + "spec:consumers.derived-readiness-banner.dishonest-divergence", + ], + [ + "test:protocol.derived-readiness-banner.honest-headroom", + "verifies", + "spec:consumers.derived-readiness-banner.honest-headroom", + ], + [ + "test:protocol.binding-language-views.bound-spec-page", + "verifies", + "spec:consumers.binding-language-views.bound-spec-page", + ], + [ + "test:protocol.binding-language-views.pack-member-table", + "verifies", + "spec:consumers.binding-language-views.pack-member-table", + ], + [ + "test:protocol.wholesale-view-rewrite.stale-page-removed", + "verifies", + "spec:consumers.wholesale-view-rewrite.stale-page-removed", + ], + [ + "test:protocol.wholesale-view-rewrite.late-stale-page", + "verifies", + "spec:consumers.wholesale-view-rewrite.late-stale-page", + ], + [ + "test:protocol.wholesale-view-rewrite.failed-run-view-removed", + "verifies", + "spec:consumers.wholesale-view-rewrite.failed-run-view-removed", + ], + [ + "test:protocol.wholesale-view-rewrite.build-invalidates-view", + "verifies", + "spec:consumers.wholesale-view-rewrite.build-invalidates-view", + ], + [ + "test:protocol.diagnostic-rendering.composed-location", + "verifies", + "spec:validation.diagnostic-rendering.composed-location", + ], + [ + "test:protocol.diagnostic-rendering.table-cell-location", + "verifies", + "spec:validation.diagnostic-rendering.table-cell-location", + ], + [ + "test:protocol.design-review.pure-projection", + "verifies", + "spec:consumers.design-review.pure-projection", + ], + [ + "test:protocol.agent-surface.scripted-context-body", + "verifies", + "spec:consumers.agent-surface.scripted-context-body", + ], + [ + "test:protocol.agent-surface.demand-map-entries", + "verifies", + "spec:consumers.agent-surface.demand-map-entries", + ], + ["test:protocol.reader.concept-entry", "verifies", "spec:consumers.reader.concept-entry"], + ["test:protocol.reader.file-entry", "verifies", "spec:consumers.reader.file-entry"], + ["test:protocol.reader.changeset-entry", "verifies", "spec:consumers.reader.changeset-entry"], + ["impl:protocol.sdp-import", "satisfies", "spec:carrier.sdp-import"], + ["impl:protocol.extraction-determinism", "satisfies", "spec:extraction.determinism"], + [ + "impl:protocol.wholesale-view-build-invalidation", + "satisfies", + "spec:consumers.wholesale-view-rewrite", + ], + ["impl:protocol.wholesale-view-rewrite", "satisfies", "spec:consumers.wholesale-view-rewrite"], + [ + "impl:protocol.derived-readiness-banner", + "satisfies", + "spec:consumers.derived-readiness-banner", + ], + [ + "impl:protocol.binding-language-spec-page", + "satisfies", + "spec:consumers.binding-language-views", + ], + [ + "impl:protocol.diagnostic-rendering-design-review", + "satisfies", + "spec:validation.diagnostic-rendering", + ], + [ + "impl:protocol.binding-language-pack-table", + "satisfies", + "spec:consumers.binding-language-views", + ], + [ + "impl:protocol.binding-language-index-table", + "satisfies", + "spec:consumers.binding-language-views", + ], + ["impl:protocol.diagnostic-rendering-cli", "satisfies", "spec:validation.diagnostic-rendering"], + ["impl:protocol.kind-evidence", "satisfies", "spec:validation.kind-evidence"], + ["impl:protocol.referential-integrity", "satisfies", "spec:validation.referential-integrity"], + ["impl:protocol.claim-separation", "satisfies", "spec:validation.claim-separation"], + ["impl:protocol.verifies-linkage", "satisfies", "spec:validation.verification-linkage"], + ["impl:protocol.oracle-linkage", "satisfies", "spec:validation.verification-linkage"], + ["impl:protocol.pack-coherence", "satisfies", "spec:validation.pack-coherence"], + ["impl:protocol.orphan-signal", "satisfies", "spec:validation.warn-level-signals"], + ["impl:protocol.authored-honesty-shape", "satisfies", "spec:validation.authored-honesty"], + [ + "impl:protocol.authored-honesty-delivery-facts", + "satisfies", + "spec:validation.authored-honesty", + ], + ["impl:protocol.verifier-gap-signal", "satisfies", "spec:validation.warn-level-signals"], + ["oracle:protocol.reader-entry-map", "models", "spec:consumers.reader"], + [ + "impl:protocol.oracle-target-eligibility", + "satisfies", + "spec:validation.oracle-target-eligibility", + ], + [ + "test:protocol.oracle-target-eligibility.rule-space-accepted", + "verifies", + "spec:validation.oracle-target-eligibility.rule-space-accepted", + ], + ["test:protocol.mermaid-view", "verifies", "spec:consumers.mermaid-view"], + [ + "test:protocol.oracle-target-eligibility.missing-space-refused", + "verifies", + "spec:validation.oracle-target-eligibility.missing-space-refused", + ], + [ + "oracle:protocol.oracle-target-eligibility", + "models", + "spec:validation.oracle-target-eligibility", + ], + ["impl:protocol.markdown-pack-authoring", "satisfies", "spec:carrier.markdown-pack-authoring"], + [ + "test:protocol.markdown-pack-authoring.markdown-ts-parity", + "verifies", + "spec:carrier.markdown-pack-authoring.markdown-ts-parity", + ], + [ + "test:protocol.markdown-pack-authoring.spec-envelope-refused", + "verifies", + "spec:carrier.markdown-pack-authoring.spec-envelope-refused", + ], + ["impl:protocol.build-pipeline-query", "satisfies", "spec:extraction.build-pipeline"], + ["impl:protocol.build-pipeline-emit", "satisfies", "spec:extraction.build-pipeline"], + ["impl:protocol.example-runner-adapter", "satisfies", "spec:extraction.example-runner"], + ["impl:protocol.runnable-modules", "satisfies", "spec:extraction.runnable-modules"], + ["test:protocol.census-page", "verifies", "spec:consumers.census-page"], + ["test:protocol.runnable-modules", "verifies", "spec:extraction.runnable-modules"], + ["impl:protocol.sdp-import-core", "satisfies", "spec:carrier.sdp-import"], + ["impl:protocol.sdp-import-markdown-emit", "satisfies", "spec:carrier.sdp-import"], + ["impl:protocol.example-testing-helpers", "satisfies", "spec:extraction.example-runner"], + ["impl:protocol.discover-files", "satisfies", "spec:extraction.excludes"], + ["impl:protocol.protocol-bindings", "satisfies", "spec:model.anchors"], + ["impl:protocol.static-reification", "satisfies", "spec:extraction.determinism"], + ["impl:protocol.carrier-reification", "satisfies", "spec:extraction.derive-graph"], + ["impl:protocol.spec-scaffolder", "satisfies", "spec:consumers.authoring-on-ramp"], + ["impl:protocol.delivery-facts", "satisfies", "spec:extraction.delivery-facts"], + ["impl:protocol.example-space", "satisfies", "spec:extraction.executable-contracts"], + ["impl:protocol.graph-index", "satisfies", "spec:validation.two-check-families"], + ["impl:protocol.validation-contracts", "satisfies", "spec:validation.two-check-families"], + ["impl:protocol.agent-surface-cli", "satisfies", "spec:consumers.agent-surface"], + ["impl:protocol.census-page-cli", "satisfies", "spec:consumers.census-page"], + ["impl:protocol.mermaid-view-cli", "satisfies", "spec:consumers.mermaid-view"], + ["impl:protocol.gherkin-view-cli", "satisfies", "spec:consumers.gherkin-view"], + [ + "test:protocol.markdown-body-grammar.table-refused", + "verifies", + "spec:carrier.markdown-body-grammar.table-refused", + ], + [ + "test:protocol.markdown-body-grammar.continuation-line-refused", + "verifies", + "spec:carrier.markdown-body-grammar.continuation-line-refused", + ], + [ + "test:protocol.markdown-body-grammar.ordered-list-refused", + "verifies", + "spec:carrier.markdown-body-grammar.ordered-list-refused", + ], + [ + "test:protocol.markdown-body-grammar.foreign-fence-refused", + "verifies", + "spec:carrier.markdown-body-grammar.foreign-fence-refused", + ], + [ + "test:protocol.markdown-body-grammar.h3-refused", + "verifies", + "spec:carrier.markdown-body-grammar.h3-refused", + ], + [ + "test:protocol.markdown-body-grammar.plain-bullet-refused", + "verifies", + "spec:carrier.markdown-body-grammar.plain-bullet-refused", + ], + [ + "test:protocol.markdown-body-grammar.keyed-bullet-refused", + "verifies", + "spec:carrier.markdown-body-grammar.keyed-bullet-refused", + ], + [ + "test:protocol.markdown-body-grammar.trailing-prose-refused", + "verifies", + "spec:carrier.markdown-body-grammar.trailing-prose-refused", + ], + [ + "test:protocol.markdown-body-grammar.repeated-field-refused", + "verifies", + "spec:carrier.markdown-body-grammar.repeated-field-refused", + ], + [ + "test:protocol.markdown-body-grammar.unrecognized-heading-refused", + "verifies", + "spec:carrier.markdown-body-grammar.unrecognized-heading-refused", + ], + [ + "test:protocol.markdown-body-grammar.second-primary-owner-refused", + "verifies", + "spec:carrier.markdown-body-grammar.second-primary-owner-refused", + ], + [ + "test:protocol.markdown-body-grammar.raw-html-refused", + "verifies", + "spec:carrier.markdown-body-grammar.raw-html-refused", + ], + [ + "test:protocol.markdown-body-grammar.shared-description-refused", + "verifies", + "spec:carrier.markdown-body-grammar.shared-description-refused", + ], + [ + "test:protocol.markdown-body-grammar.integer-term-refused", + "verifies", + "spec:carrier.markdown-body-grammar.integer-term-refused", + ], + ["impl:protocol.markdown-body-grammar", "satisfies", "spec:carrier.markdown-body-grammar"], + ["impl:protocol.inline-code-spans", "satisfies", "spec:carrier.inline-code-spans"], + ["oracle:protocol.inline-code-spans", "models", "spec:carrier.inline-code-spans"], + [ + "test:protocol.inline-code-spans.narrative", + "verifies", + "spec:carrier.inline-code-spans.narrative", + ], + [ + "test:protocol.inline-code-spans.description", + "verifies", + "spec:carrier.inline-code-spans.description", + ], + ["test:protocol.inline-code-spans.list", "verifies", "spec:carrier.inline-code-spans.list"], + ["test:protocol.inline-code-spans.pack", "verifies", "spec:carrier.inline-code-spans.pack"], + ["impl:protocol.unbound-example-posture", "satisfies", "spec:validation.unbound-example-posture"], + ["oracle:protocol.unbound-example-posture", "models", "spec:validation.unbound-example-posture"], + [ + "test:protocol.unbound-example-posture.lower-rungs", + "verifies", + "spec:validation.unbound-example-posture.lower-rungs", + ], + [ + "test:protocol.unbound-example-posture.warning-cases", + "verifies", + "spec:validation.unbound-example-posture.warning-cases", + ], + [ + "test:protocol.unbound-example-posture.parent-traces", + "verifies", + "spec:validation.unbound-example-posture.parent-traces", + ], + ["impl:protocol.typed-dependency-floor", "satisfies", "spec:validation.typed-dependency-floor"], + ["oracle:protocol.typed-dependency-floor", "models", "spec:validation.typed-dependency-floor"], + [ + "test:protocol.typed-dependency-floor.relation-rungs", + "verifies", + "spec:validation.typed-dependency-floor.relation-rungs", + ], + [ + "test:protocol.typed-dependency-floor.missing-targets", + "verifies", + "spec:validation.typed-dependency-floor.missing-targets", + ], + [ + "test:protocol.typed-dependency-floor.unsettled-fact", + "verifies", + "spec:validation.typed-dependency-floor.unsettled-fact", + ], + [ + "test:protocol.typed-dependency-floor.stated-readiness", + "verifies", + "spec:validation.typed-dependency-floor.stated-readiness", + ], + ["impl:protocol.register-recipes", "satisfies", "spec:consumers.agent-surface.register-recipes"], + ["test:protocol.register-recipes", "verifies", "spec:consumers.agent-surface.register-recipes"], + ["impl:protocol.adopter-on-ramp", "satisfies", "spec:consumers.adopter-on-ramp"], + ["test:protocol.adopter-on-ramp", "verifies", "spec:consumers.adopter-on-ramp"], + ["impl:protocol.shipped-protocol-corpus", "satisfies", "spec:consumers.shipped-protocol-corpus"], + ["test:protocol.shipped-protocol-corpus", "verifies", "spec:consumers.shipped-protocol-corpus"], + ["impl:protocol.prose-mentions", "satisfies", "spec:validation.prose-mentions"], + ["test:protocol.prose-mentions", "verifies", "spec:validation.prose-mentions"], + ["impl:protocol.pack-member-order", "satisfies", "spec:extraction.pack-member-order"], + ["impl:protocol.open-question-keys", "satisfies", "spec:model.open-question-keys"], + ["impl:protocol.next-rung-floor", "satisfies", "spec:validation.next-rung-floor"], + [ + "impl:protocol.address-and-cycle-recipes", + "satisfies", + "spec:consumers.agent-surface.address-and-cycle-recipes", + ], + [ + "test:protocol.address-and-cycle-recipes", + "verifies", + "spec:consumers.agent-surface.address-and-cycle-recipes", + ], + ["oracle:protocol.next-rung-floor", "models", "spec:validation.next-rung-floor"], + [ + "test:protocol.next-rung-floor.typed-dependency-targets", + "verifies", + "spec:validation.next-rung-floor.typed-dependency-targets", + ], + [ + "test:protocol.next-rung-floor.blocking-question", + "verifies", + "spec:validation.next-rung-floor.blocking-question", + ], + ["oracle:protocol.open-question-keys", "models", "spec:model.open-question-keys"], + [ + "test:protocol.open-question-keys.keyed-question-resolves", + "verifies", + "spec:model.open-question-keys.keyed-question-resolves", + ], + [ + "test:protocol.open-question-keys.renamed-key-breaks", + "verifies", + "spec:model.open-question-keys.renamed-key-breaks", + ], + [ + "test:protocol.open-question-keys.repeated-key-refused", + "verifies", + "spec:model.open-question-keys.repeated-key-refused", + ], + [ + "test:protocol.pack-member-order", + "verifies", + "spec:extraction.pack-member-order.manifest-order-kept", + ], + // Each unit references the decision it answers to (plan 40's rulings, R1); a design + // that builds on existing code states that by its own relation, never by a reference here. + ["impl:protocol.anchors-subpath", "satisfies", "spec:model.anchors"], + ["impl:protocol.anchors-subpath", "references", "spec:decisions.anchor-comment-form"], + ["impl:protocol.anchor-extraction", "references", "spec:decisions.anchor-comment-form"], + ["impl:protocol.derive-graph", "references", "spec:decisions.anchor-binding-grain"], + ["impl:protocol.graph-edge-types", "satisfies", "spec:extraction.derive-graph"], + ["impl:protocol.graph-edge-types", "references", "spec:decisions.anchor-binding-grain"], + ["impl:protocol.anchor-model", "references", "spec:decisions.architectural-annotation"], + ["impl:protocol.census-page", "references", "spec:decisions.architectural-annotation"], +] as const; + +// re-measured under plan 40 +export const expectedCommentAnchorIds = [ + "impl:protocol.reader-impact", + "component:protocol.reader", + "impl:protocol.agent-surface", + "impl:protocol.reader", + "impl:protocol.census-page", + "impl:protocol.derived-readiness-banner", + "impl:protocol.binding-language-spec-page", + "impl:protocol.diagnostic-rendering-design-review", + "impl:protocol.binding-language-pack-table", + "impl:protocol.binding-language-index-table", + "component:protocol.projections", + "impl:protocol.projections-model", + "impl:protocol.design-review", + "impl:protocol.gherkin-view", + "impl:protocol.mermaid-view", + "impl:protocol.agent-surface-cli", + "impl:protocol.anchors-subpath", +] as const; diff --git a/test/self-hosting-oracle/decisions.ts b/test/self-hosting-oracle/decisions.ts index 3e9dd8e0..22c86db2 100644 --- a/test/self-hosting-oracle/decisions.ts +++ b/test/self-hosting-oracle/decisions.ts @@ -1229,4 +1229,158 @@ export const decisionsSpecs = [ deliveryFacts: [], file: "specs/decisions/studio-web-components.sdp.md", }, + { + id: "spec:decisions.anchor-comment-form", + specKind: "decision", + altitude: "feature", + readiness: "ready", + file: "specs/decisions/anchor-comment-form.sdp.md", + title: "The comment form is a second anchor representation", + narrative: null, + sections: { + intent: { + outcome: + "Admit a doc comment carrying reserved `@sdp*` tags as a second extracted representation of the anchor, feeding the same closed envelope as the constant form, so code that cannot import the Protocol binds at its real site and comment prose still authors nothing.", + }, + decision: { + context: + "The source-commentary ruling it supersedes (`spec:decisions.jsdoc-graph-extraction-refused`, MD-35) refused two things in one record: comment prose authoring graph content, and identity tags in doc comments. Its rationale said the refusal \"is about prose, not identity\", while its operative alternative also refused structured identity tags; the annotation scout under `docs/lineage/` names that mismatch and finds that gen 1's drift was duplicate identities, scan scope, and syntax handling, never an extracted identity tag as such. The first adopter showed the cost of the single form: every one of its code anchors sits in a test or a harness because its runtime code cannot import this package, `byFile` on a source file returns nothing, and a hand-kept join stands in for the binding. The original design (`docs/lineage/v0-design/04-authoring-surfaces.md` §2) supported three marker styles feeding one extractor, and its package shape (`docs/lineage/v0-design/02-system-architecture.md` §3) kept the marker library free of runtime dependencies so product code could import it. The owner's direction opening plan 40 is to move closer to that design.", + decision: + "An anchor has two extracted representations that feed one closed envelope, the constant form as ruled and a comment form. The comment form is a top-level `/** … */` block in a `.ts` or `.tsx` file carrying reserved camelCase, TSDoc-compatible tags; `@sdpAnchor ` opens it, and the id's namespace selects the flavor exactly as the constant builders do. Comment prose still authors nothing: only the reserved tags are read, and a comment that does not open with `/**` is never read. Trust is by reserved grammar, not by builder import, so no import is required. The same id in two forms is a duplicate id. The package also ships a zero-dependency subpath `@libar-dev/software-delivery-protocol/anchors` exporting the id builders and the three anchor builders and nothing else, for runtimes that may import but must not load `node:*` modules or ts-morph; the extractor trusts that specifier as a Protocol builder module.", + rationale: [ + "Hard to reverse: a second write path from source becomes a contract for the extractor, the validators, and every adopter's code. Surprising without context: the superseded ruling refused the obvious parity move, and this one admits exactly the half its own rationale left open while keeping the half it was about. Real trade-off: a tag grammar in a comment gets no compiler check and the extractor gains a second parser; in exchange, code that cannot import the package binds at its real site and `byFile` answers for it.", + "What survives from the superseded ruling: comment prose authors nothing; a comment's wording beyond the reserved tags produces no graph finding; a comment that states law other surfaces depend on still promotes into a Spec under the promotion law, and restating promoted law in a comment violates exclusive promotion (MD-10), so the comment demotes to local commentary plus a Spec pointer; one graph language stays, because both forms feed one envelope and one extraction path (one validation path, MD-14).", + ], + consequences: [ + "The reserved tags are `@sdpAnchor`, `@sdpLabel`, `@sdpSatisfies`, `@sdpVerifies`, `@sdpModels`, `@sdpReferences`, `@sdpComponent`, `@sdpUses`, `@sdpRole`, `@sdpLayer`, and `@sdpContext`; the spelling is camelCase so TSDoc tooling reads each as one tag.", + "`@sdpAnchor ` opens the anchor; `impl:`, `api:`, and `component:` ids are code anchors, `test:` is a test anchor, `oracle:` is an oracle anchor.", + "Target tags by flavor are `@sdpSatisfies` on a code anchor, `@sdpVerifies` on a test anchor, and `@sdpModels` on an oracle anchor; lists are comma-separated.", + "Structural tags are admitted on code anchors only: `@sdpComponent`, `@sdpUses`, `@sdpReferences`, `@sdpRole`; a component anchor additionally takes `@sdpLayer` and `@sdpContext`.", + "`@sdpLabel` is admitted on any flavor.", + "Any other `@sdp*` tag is an envelope error, because the envelope is closed exactly as it is in the constant form; a line that opens with `@sdp` is a reserved tag line, so a misspelling such as `@sdp-anchor` or `@sdpAnchor:` is refused rather than read as prose.", + "Attachment: every top-level `/** … */` block that contains a reserved tag is one anchor with exactly one `@sdpAnchor`; a block with reserved tags and no `@sdpAnchor` is an envelope error, and a block with two is an envelope error, as any repeated tag is.", + "Blocks above the first import, trailing blocks with no statement beneath them, and comment-only files are read; a `/** … */` block with a reserved tag in a nested position, inside a function body, a class member, an object literal, or a JSX expression, is a misplaced-tag error, and a nested comment the top level would not read stays unread.", + "The block's first line is the binding's file and line.", + "The binding grain of both forms is the file: a block's line locates the anchor and names no declaration, and no attachment kind or symbol name is recorded. Symbol-level reach, if it arrives, is derived under the inferred claim and joins anchors by position; a mismatch is never a finding.", + "Cardinalities: each reserved tag appears at most once per block; `@sdpModels` takes one target; a list tag is non-empty, carries no empty item, and repeats no target.", + "Prose precedes the tags; after the first reserved tag, a non-empty line that does not open a tag is a refused continuation, and an ordinary TSDoc tag such as `@param` stays lawful and unread.", + "Parsing reads the raw comment text with a tag grammar; it does not depend on the TypeScript JSDoc tag parser.", + "The `hasProtocolBuilderImport` prefilter admits a file that contains the `@sdp` prefix.", + "The same id written in both forms reports through the existing duplicate-id validator.", + "The `/anchors` subpath is ESM with type declarations, built from the leaf id and anchor modules, carries no dependency, loads no `node:*` module, and its dependency closure is tested; a runtime that imports it ships nothing beyond the id and anchor builders.", + "The decorator form stays an unextracted representation.", + ], + alternatives: [ + "Keeping the constant form as the single binding syntax was refused: the adopter's runtime code cannot import this package, so its anchors would stay in tests and the graph would keep answering nothing for source files.", + "Extracting the comment form through the TypeScript JSDoc tag parser was refused: it would couple the reserved grammar to the compiler's tag model and its versioning, where a tag grammar over the raw comment text is owned here.", + "A hyphenated spelling such as `@sdp-anchor` was refused: TSDoc tooling splits a hyphenated tag at the hyphen, so the tag would render as `@sdp` with a stray suffix in every documentation projection.", + "Parsing comment prose into Spec sections stays refused: it inverts the separation of intent from binding and recreates a shadow intent carrier beside the Specs.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:decisions.anchor-binding-grain", + specKind: "decision", + altitude: "feature", + readiness: "ready", + file: "specs/decisions/anchor-binding-grain.sdp.md", + title: "Bindings are optional, plural, and may reference a design", + narrative: null, + sections: { + intent: { + outcome: + "Let a code anchor name zero, one, or several Specs it realizes, and name the Specs it answers to without claiming to realize them, so an anchor can state exactly what its code does and the graph can reach a design from the code that answers to it.", + }, + decision: { + context: + "A code anchor carries one required `satisfies`, a test anchor one `verifies`. The first adopter split Specs so that an anchor could claim only what its code does, a stub reads as `implemented` because the only way to name a design from code is to claim to realize it, and a relationship the design states in prose cannot reach a page. The original design made `satisfies` optional and plural on a marker (`docs/lineage/v0-design/04-authoring-surfaces.md` §2.4) and drew the line that markers carry identity and structure, never readiness, intent, behavior, or verification. The annotation scout under `docs/lineage/` found no informational code-to-Spec edge in either lineage and proposed one as the smallest extension.", + decision: + "On a code anchor, `satisfies` is optional and plural; on a test anchor, `verifies` is plural and non-empty. The constant form accepts one `ref(…)` or a fresh array literal of them. Each resolving target confers its fact as today, and `implemented` stays a whole-Spec fact. A new anchored edge `references` runs from a CodeNode to a Spec, from `references?: readonly SpecId[]` on a code anchor; it may target any Spec, decisions included, confers no delivery fact, moves no readiness floor, and the drift alarm ignores it. It says this code answers to that design without claiming to realize it: the unit follows the design or realizes part of it, so a change to the design asks the code to follow. A design that builds on existing code states that by a typed relation to the Spec the code satisfies, never by a reference from the code. A target named in both `satisfies` and `references` is an error. An identity-only code anchor, with no `satisfies` and no `references`, is lawful: it mints a CodeNode for structure and for `byFile`, and nothing else.", + rationale: [ + "Hard to reverse: the anchor contract and the closed edge list are contracts for the extractor, the validators, the reader, and every adopter's anchors. Surprising without context: the obvious move is to let `satisfies` name a design the code only partly realizes, and this ruling refuses that in favor of a second edge that confers nothing. Real trade-off: a non-conferring edge can be written where a realization claim is due, and nothing in the graph says which; in exchange, `implemented` keeps its one meaning and an honest anchor is always writable.", + "Bindings state existence, not liveness, and `references` states less than existence of realization: only that the code answers to the design. Keeping that below the delivery facts preserves the drift alarm's meaning.", + ], + consequences: [ + "The closed edge list gains `references`, derived and anchored; `satisfies` and `verifies` edges are one per target.", + "A present `references` is non-empty and unique; a non-resolving target confers nothing and reports as a non-resolving `satisfies` target does.", + "`satisfies` keeps its posture: code never satisfies a decision Spec directly (MD-26), and an anchor is never pointed at an unfinished Spec to manufacture coverage; a unit that answers to such a Spec references it.", + "An identity-only anchor derives no `satisfies` edge, so it never confers `implemented`; a stub that answers to a design references the design rather than satisfying it.", + "`byFile` may lawfully return a CodeNode with an empty Spec list; an empty list is the identity-only anchor's honest answer, not a lookup failure.", + "`blastRadius` reports changed identity-only CodeNodes as their own list, neither dropped nor folded into coverage-unknown, because the graph records the node and nothing it binds.", + "The drift alarm ignores `references` and still reports a `satisfies` target that states below `ready`, target by target.", + "The build backlog predicate (recipe 1) is unchanged: `references` neither adds nor removes a row.", + "The reader's Spec context names the CodeNodes that reference the Spec with their file and line; blast radius traverses `references` as a binding and names the edge type in its reason.", + "The Design Review Spec page lists the units that reference the Spec, and the census renders `references` beside the structural edges.", + "Anchor targets name whole Specs. An entry address never narrows a binding and never carries a delivery fact; a part of a Spec that needs its own realization tracking is its own Spec.", + "A `references` edge is authored for the design relationship it names; an edge whose reason is a test, a count, or a `byFile` answer is removed.", + ], + alternatives: [ + 'A partial-realization delivery fact was refused: a fact that says "partly implemented" is a status on code, and the floor cannot read it honestly.', + "Folding the design relationship into `satisfies` was refused: it manufactures `implemented` on a Spec the code does not realize, which is the adopter's stub problem restated.", + "Declaring the relationship on the Spec, as a list of its own code bindings, was refused: a Spec-side list of bindings is authored delivery state and a second place for the binding to drift.", + "References from code that a design builds on were refused: that dependency is a relation from the building design to the Spec the code satisfies; a code-side copy is a second home for it and would make each unit's source list its consumers' designs.", + "A `satisfies` or `references` target at an entry address was refused: a per-entry `implemented` is the partial-realization fact by another name, it would put delivery facts below the Spec, and a design key is renamed freely while an anchor bound to one would break on every design edit.", + ], + }, + }, + deliveryFacts: [], + }, + { + id: "spec:decisions.architectural-annotation", + specKind: "decision", + altitude: "feature", + readiness: "ready", + file: "specs/decisions/architectural-annotation.sdp.md", + title: "Architectural significance is annotated where it is realized", + narrative: null, + sections: { + intent: { + outcome: + "Let a code anchor state the architectural role its unit plays, and let a component anchor state its layer and bounded context, so the graph answers architecture questions from the code that realizes the architecture, without a status, a second vocabulary registry, or a second architecture file.", + }, + decision: { + context: + "Two rulings carried the structural half of the anchor: structural anchors confer nothing (`spec:decisions.structural-anchor-semantics`, MD-30) admitted `component` and `uses` as the only structural fields, and architectural significance rides existing primitives (`spec:decisions.architectural-significance-rides-primitives`, MD-34) refused a vocabulary beyond them and gave up CodeNode-grain roles. Both cited gen 1's tag-registry drift, about 50 tags to about 26, as the failure of an open structural vocabulary. The annotation scout under `docs/lineage/` corrects that account: the 50-to-26 collapse was a cleanup of gen 1's formal vocabulary that never shipped as a runtime registry, and what drifted there was duplicate identities, scan scope, and syntax handling. The original design declared a Component with `layer` and `bounded_context` (`docs/lineage/v0-design/03-graph-metamodel.md` §2.5), and gen 1 carried a role on each annotated unit. The owner's direction opening plan 40 is to bring back the value of annotating architecturally significant units and their relationships in code.", + decision: + "Architectural significance is annotated on the anchor of the unit that realizes it. A code anchor may carry `role`, the architectural pattern the unit plays; a `component:` anchor may additionally carry `layer`, one of `edge`, `application`, `domain`, `adapter`, or `infrastructure`, and `context`, its bounded context. The `component:` anchor is anchored architecture: it is the declared component, and there is no separate architecture file, because the anchor sits where the component is realized. It is not the original design's declared Component that could exist before code; an unrealized component lives in Specs and never as an invented CodeNode. A role is a free, corpus-owned vocabulary whose taxonomy the census renders with counts from the graph; `service`, `decider`, `projection`, `read-model`, `codec`, `contract`, `barrel`, and `utility` are the lineage set, not a closed list. `layer` is a closed set, and a value outside it is an envelope error; `layer` or `context` on a non-component anchor is an envelope error. All three attributes are optional, and omission is lawful.", + rationale: [ + "Hard to reverse: the anchor envelope and the CodeNode fields are contracts for the extractor, the census, the Design Review, and every adopter's annotations. Surprising without context: the superseded rulings refused exactly these attributes on the strength of a drift account the scout found inaccurate, so the reversal reads as a repeat of gen 1 until the correction is on the table. Real trade-off: a free role vocabulary can drift across a corpus and no validator will say so; in exchange, the role, layer, and context live on the one anchor at the one site, and the census taxonomy makes drift visible without a registry.", + "What survives from both superseded rulings is restated here as consequences: the `component` and `uses` semantics and their validation rules, structural non-conferral, no inference from imports, no status or readiness on code, significance never selects a Spec kind, no `pattern:` namespace, and the engine significance criterion.", + ], + consequences: [ + "`component?: ComponentAnchorId` and `uses?: readonly CodeAnchorId[]` stay closed graph-ID references deriving only anchored `memberOf` and `uses` edges; neither accepts a free string or an enum detached from graph identity, and there are no per-namespace sibling builders.", + "Every `component` and `uses` target exists as a CodeNode, and a dangling graph id is an error.", + "Membership is one level: a `memberOf` edge runs only from an `impl:` or `api:` CodeNode to a `component:` CodeNode, each source has at most one component, and a `component:` node is never itself a member.", + "A `uses` edge runs between CodeNode endpoints in the `impl:`, `api:`, or `component:` namespace; a present `uses` is non-empty and unique, structural edges are unique, and structural self-reference is an error.", + "Multi-node `uses` cycles remain authored data for the census, never findings; validators infer no transitive edge and reject no cycle.", + "A malformed or non-static structural field, an unknown `layer`, or `layer` or `context` on a non-component anchor refuses the whole anchor at reification rather than yielding a partial declaration; the envelope stays closed.", + "Structural edges and the new attributes confer no intent, delivery fact, or readiness effect; `satisfies` stays the only code-to-Spec realization slot and no `implements` field is admitted.", + "Architecture is never inferred from imports as anchored or declared structure; an inferred structural layer, if one arrives, stays advisory under its own claim, and derivation adds no fourth claim.", + "No status, readiness, intent, behavior, or verification is carried on code; role, layer, and context describe what the unit is, never where it stands.", + "Anchor-required lint stays warn-level and optional; the absence of an anchor is evidence, never a workflow gate.", + "Architectural significance never selects a Spec kind; the Spec states the kind of truth it carries, and relationships among such Specs are the existing relations, with `dependsOn` reserved for genuine semantic need, `supersedes` for actual replacement, and scheduling-flavored edges refused (planning truths live in ruled graph homes, MD-33).", + "Negative architecture constraints, a layer that must not use another, stay declared intent in a Spec, never a finding of the Protocol's validators; a corpus that wants one enforced checks its declared `uses`, `layer`, and `context` in its own gate.", + "No `pattern` term, kind, or `pattern:` namespace is admitted; a role is an attribute of a CodeNode, not a node; grouping is derived from id families and the component graph, not new Packs.", + "The engine significance criterion stays exported public surface or cross-component reach; code never satisfies a decision Spec directly (MD-26), and an anchor is never pointed at an unfinished Spec to manufacture coverage.", + "`role` and `context` values are one lowercase kebab token matching `^[a-z][a-z0-9-]*$`, with no normalization, so `read-model`, `readModel`, and `Read Model` cannot coexist as three categories; a value outside the token grammar is an envelope error.", + "The census links each role, layer, and context value to the units that carry it, so an owner can reconcile two spellings of one category by editing the anchors; nothing forces a label, and an unlabelled unit does not populate the taxonomy.", + "The five layers, one line each: `edge` is the transport and presentation boundary; `application` is use cases and orchestration; `domain` is the model and its rules; `adapter` is the implementations of ports toward external systems; `infrastructure` is runtime, persistence, and platform plumbing.", + "A package may hold several layers or several contexts; the package-to-component mapping is reviewed, and one component per package is a choice, never a rule.", + "Retained limitation: there is no independently declared Component; a component that no code realizes yet is a Spec, and the graph holds no CodeNode for it until an anchor exists.", + "The census renders role, layer, context, and `references` beside the structural edges it already renders, reporting an explicit empty state when none exist, and the Design Review's component rows show layer and context; both render under their existing contracts, Mermaid and Gherkin are untouched, and the shipped projections stay frozen (MD-32) as it stands, because a frozen projection renders the graph it is given and no projection is re-specified.", + "Annotations are curated, never a coverage quota; an identity has one owner; a realizing unit documents its local how, never a paraphrase of the Spec's what or why.", + ], + alternatives: [ + "A separate architecture file declaring components, layers, and contexts was refused: it is a second place for the component to drift from the code that realizes it.", + "A closed role enum was refused: the corpus owns its architecture vocabulary, and a closed set would be the Protocol's architecture, not the adopter's.", + "A corpus-declared role vocabulary checked by a validator was refused: it is an authored registry, and the check is a content-quality check. The token grammar and the census taxonomy with its units are the whole check; a corpus that wants a list lints recipe 28 in its own gate.", + "Deriving role, layer, or context from file paths or imports was refused: it turns incidental layout into authoritative architecture and misclassifies an inferred observation as an anchored claim.", + "Adding `implements` was refused again: it would duplicate `satisfies` for contract-kind targets and make code-to-Spec realization ambiguous.", + ], + }, + }, + deliveryFacts: [], + }, ] as const; diff --git a/test/self-hosting-oracle/declared-relations.ts b/test/self-hosting-oracle/declared-relations.ts index 7e556ec7..d2560230 100644 --- a/test/self-hosting-oracle/declared-relations.ts +++ b/test/self-hosting-oracle/declared-relations.ts @@ -6,7 +6,6 @@ export const expectedDeclaredRelations = [ ["spec:extraction.derive-graph", "dependsOn", "spec:extraction.delivery-facts"], ["spec:consumers.reader", "dependsOn", "spec:extraction.delivery-facts"], ["spec:validation.authored-honesty", "dependsOn", "spec:extraction.delivery-facts"], - ["spec:model.anchors", "decidedBy", "spec:decisions.jsdoc-graph-extraction-refused"], ["spec:carrier.markdown-authoring", "dependsOn", "spec:carrier.markdown-parser"], ["spec:carrier.markdown-authoring", "decidedBy", "spec:decisions.sdp-ts-extension"], ["spec:carrier.markdown-authoring", "decidedBy", "spec:decisions.carrier-ruling"], @@ -156,18 +155,8 @@ export const expectedDeclaredRelations = [ ["spec:protocol.self-hosting", "decidedBy", "spec:decisions.protocol-naming"], ["spec:protocol.self-hosting", "decidedBy", "spec:decisions.plain-language-references"], ["spec:protocol.structural-self-binding", "refines", "spec:protocol.self-hosting"], - [ - "spec:protocol.structural-self-binding", - "decidedBy", - "spec:decisions.architectural-significance-rides-primitives", - ], ["spec:consumers.graph-first-planning", "refines", "spec:consumers.delivery-session-on-ramp"], ["spec:model.structural-patterns", "refines", "spec:model.anchors"], - [ - "spec:model.structural-patterns", - "decidedBy", - "spec:decisions.architectural-significance-rides-primitives", - ], ["spec:extraction.derive-graph", "refines", "spec:protocol.self-hosting"], ["spec:extraction.derive-graph", "constrainedBy", "spec:extraction.determinism"], ["spec:extraction.derive-graph", "decidedBy", "spec:decisions.one-validation-path"], @@ -452,7 +441,6 @@ export const expectedDeclaredRelations = [ ["spec:model.pack-aggregate", "decidedBy", "spec:decisions.pack-reified"], ["spec:model.anchors", "refines", "spec:model.core-model"], ["spec:model.anchors", "decidedBy", "spec:decisions.binding-not-liveness"], - ["spec:model.anchors", "decidedBy", "spec:decisions.structural-anchor-semantics"], ["spec:decisions.plain-language-references", "refines", "spec:protocol.self-hosting"], ["spec:decisions.concept-docs-dissolve", "refines", "spec:protocol.self-hosting"], ["spec:decisions.one-validation-path", "refines", "spec:validation.two-check-families"], @@ -1124,4 +1112,31 @@ export const expectedDeclaredRelations = [ "verifies", "spec:validation.next-rung-floor", ], + ["spec:decisions.anchor-binding-grain", "refines", "spec:model.anchors"], + ["spec:decisions.anchor-binding-grain", "dependsOn", "spec:decisions.binding-not-liveness"], + ["spec:decisions.anchor-comment-form", "refines", "spec:model.anchors"], + [ + "spec:decisions.anchor-comment-form", + "supersedes", + "spec:decisions.jsdoc-graph-extraction-refused", + ], + ["spec:decisions.anchor-comment-form", "dependsOn", "spec:decisions.one-validation-path"], + ["spec:decisions.architectural-annotation", "refines", "spec:model.anchors"], + [ + "spec:decisions.architectural-annotation", + "supersedes", + "spec:decisions.structural-anchor-semantics", + ], + [ + "spec:decisions.architectural-annotation", + "supersedes", + "spec:decisions.architectural-significance-rides-primitives", + ], + ["spec:decisions.architectural-annotation", "dependsOn", "spec:decisions.binding-not-liveness"], + ["spec:decisions.architectural-annotation", "dependsOn", "spec:decisions.anchor-binding-grain"], + ["spec:model.anchors", "decidedBy", "spec:decisions.anchor-comment-form"], + ["spec:model.anchors", "decidedBy", "spec:decisions.anchor-binding-grain"], + ["spec:model.anchors", "decidedBy", "spec:decisions.architectural-annotation"], + ["spec:model.structural-patterns", "decidedBy", "spec:decisions.architectural-annotation"], + ["spec:protocol.structural-self-binding", "decidedBy", "spec:decisions.architectural-annotation"], ] as const; diff --git a/test/self-hosting-oracle/extraction.ts b/test/self-hosting-oracle/extraction.ts index 61c438ce..89b12781 100644 --- a/test/self-hosting-oracle/extraction.ts +++ b/test/self-hosting-oracle/extraction.ts @@ -20,7 +20,9 @@ export const extractionSpecs = [ rules: [ "Carrier reification feeds deriveGraph once; no consumer creates a second graph.", "The graph is flat arrays of typed nodes and edges; hierarchy and containment are expressed by edges rather than nested nodes.", - "Declared relations resolve Primitive to Primitive, while `satisfies` and test `verifies` edges derive from anchors and run from their binding node to the direct Spec target.", + "Declared relations resolve Primitive to Primitive, while `satisfies`, `references`, and test `verifies` edges derive from anchors and run from their binding node to the direct Spec target, one edge per target.", + "The edge list is closed at twelve types: the six declared relations `refines`, `dependsOn`, `constrainedBy`, `decidedBy`, `verifies`, and `supersedes`, and the six derived edges `belongsTo`, `satisfies`, `models`, `memberOf`, `uses`, and `references`.", + "A `references` edge is anchored, runs from a CodeNode to the Spec its code answers to, and contributes no delivery fact.", "Delivery facts are computed node facts: a resolving `satisfies` edge contributes `implemented`, and an enabled direct verifier contributes `has-verifier` only to its target.", "Inferred structural edges are advisory inputs to impact analysis and never become authoritative graph truth.", "The key order of `design`, `ui`, and `model.terms` is the authored order and part of the graph contract.", @@ -293,7 +295,8 @@ export const extractionSpecs = [ 'a graph derived from the authored spec {specId: "spec:probe.schema-versioning"}', ], when: ["the graph payload is serialized"], - then: ['the payload declares the schema version {schemaVersion: "0.7.0"}'], + // re-measured under plan 40 + then: ['the payload declares the schema version {schemaVersion: "0.8.0"}'], }, ], }, @@ -786,7 +789,8 @@ export const extractionSpecs = [ 'the serialized Pack node lists the members {serializedMembers: "spec:probe.zeta, spec:probe.alpha, spec:probe.mid"}', 'the serialized belongsTo edges run from {edgeSources: "spec:probe.alpha, spec:probe.mid, spec:probe.zeta"}', 'the reader\'s Pack context lists the members {readerMembers: "spec:probe.zeta, spec:probe.alpha, spec:probe.mid"}', - 'the payload declares the schema version {schemaVersion: "0.7.0"}', + // re-measured under plan 40 + 'the payload declares the schema version {schemaVersion: "0.8.0"}', ], }, ], diff --git a/test/self-hosting-oracle/index.ts b/test/self-hosting-oracle/index.ts index 7bffd674..85b2cc44 100644 --- a/test/self-hosting-oracle/index.ts +++ b/test/self-hosting-oracle/index.ts @@ -50,10 +50,11 @@ export const expectedSpecs: readonly ExpectedSpec[] = specFamilies.flatMap( ); // These projections identify the five informative honesty gaps from owner-ratified ready Specs -// without resolving verifier bindings, and the eight informative prose-mention warnings, one per +// without resolving verifier bindings, and the nine informative prose-mention warnings, one per // mentioning and target Spec pair with no declared relation either way. A prose-mention row also // pins its target Spec as relatedId, so two pairs from one Spec differ in the pin. The CLI suite // separately pins each diagnostic's file, validator, and message prefix. +// re-measured under plan 40 export const expectedWarnings = [ { validatorId: "honesty/gaps", @@ -96,6 +97,13 @@ export const expectedWarnings = [ subjectId: "spec:consumers.delivery-session-on-ramp", relatedId: "spec:decisions.shipped-projections-frozen", }, + { + validatorId: "conformance/prose-mentions", + family: "conformance", + severity: "warning", + subjectId: "spec:decisions.architectural-significance-rides-primitives", + relatedId: "spec:model.structural-patterns", + }, { validatorId: "conformance/prose-mentions", family: "conformance", @@ -143,7 +151,8 @@ export const expectedWarnings = [ }, ] as const; -export { expectedAnchors } from "./anchors.js"; +export type { ExpectedAnchor } from "./anchors.js"; +export { expectedAnchors, expectedBindingEdges, expectedCommentAnchorIds } from "./anchors.js"; export { expectedDeclaredRelations } from "./declared-relations.js"; export { expectedPackMembers, expectedStudioPackMembers, expectedPacks } from "./pack-members.js"; export { diff --git a/test/self-hosting-oracle/model.ts b/test/self-hosting-oracle/model.ts index fdfc141e..05159c1f 100644 --- a/test/self-hosting-oracle/model.ts +++ b/test/self-hosting-oracle/model.ts @@ -313,6 +313,51 @@ export const modelSpecs = [ outcome: "Connect implementation, tests, and oracles to Specs while keeping authored intent centralized in the carrier.", }, + model: { + terms: { + anchor: + "A human-written source binding from one code location into the graph, carrying identity, an optional label, zero or more targets, and optional structure; written in one of two extracted representations, the constant form or the comment form, which feed one closed envelope. It never carries behavior, rationale, readiness, status, acceptance criteria, or delivery facts.", + "constant form": + "The top-level const initialized with a trusted builder call; the extractor reifies it statically. It was formerly named the anchor-constant form. The decorator form remains an unextracted representation.", + "comment form": + "A top-level `/** … */` block in a `.ts` or `.tsx` file carrying reserved camelCase tags (`@sdpAnchor`, `@sdpLabel`, `@sdpSatisfies`, `@sdpVerifies`, `@sdpModels`, `@sdpReferences`, `@sdpComponent`, `@sdpUses`, `@sdpRole`, `@sdpLayer`, `@sdpContext`); `@sdpAnchor ` opens it, each tag appears at most once, prose precedes the tags, only the reserved tags are read, and comment prose authors nothing. No import is required: trust is by reserved grammar. The same id in both forms is a duplicate id. The law is the comment-form ruling (MD-36).", + "anchors subpath": + "The zero-dependency package subpath `@libar-dev/software-delivery-protocol/anchors`, exporting the id builders and the three anchor builders and nothing else; the extractor trusts it as a Protocol builder module.", + "code anchor": + "An implementation-flavored binding in the `impl:`, `api:`, or `component:` namespace. Its optional, plural `satisfies` derives one anchored `satisfies` edge per target; its optional `references` derives one anchored `references` edge per target. It may additionally name one `component?: ComponentAnchorId`, a non-empty, unique `uses?: readonly CodeAnchorId[]`, and a `role`; a `component:` anchor may also name `layer` and `context`. The binding grain is the binding-grain ruling (MD-37); the structural attributes are the architectural-annotation ruling (MD-38).", + "plural targets": + "`satisfies` on a code anchor and `verifies` on a test anchor accept one `ref(…)` or a fresh array literal of them; `verifies` is non-empty. Each resolving target confers its fact as a single target does, and `implemented` stays a whole-Spec fact.", + "identity-only anchor": + "A code anchor with no `satisfies` and no `references`; it mints a CodeNode for structure and for `byFile`, derives no realization edge, and confers nothing.", + references: + "The anchored CodeNode-to-Spec edge derived from `references?: readonly SpecId[]` on a code anchor: this code answers to that design without claiming to realize it. The unit follows the design or realizes part of it, so a change to the design asks the code to follow. The direction is one way: a design that builds on existing code states that on its own side, by a `dependsOn` or `refines` relation to the Spec the code satisfies, never by a reference from the code. It confers no delivery fact, moves no readiness floor, and the drift alarm ignores it; a target also named in `satisfies` is an error. The reader reports the referencing units on the Spec's context, and blast radius traverses the edge as a binding.", + role: "An optional free string on a code anchor naming the architectural pattern the unit plays; a corpus-owned vocabulary whose taxonomy the census renders with counts, checked against no list.", + layer: + "An optional attribute of a `component:` anchor, one of `edge`, `application`, `domain`, `adapter`, or `infrastructure`; a value outside the set, or the attribute on a non-component anchor, is an envelope error.", + context: + "An optional free string on a `component:` anchor naming its bounded context; on a non-component anchor it is an envelope error.", + "declared component": + "The `component:` anchor is the component's declaration; there is no separate architecture file, because the anchor sits where the component is realized.", + "component realization convention": + "A `component:` anchor that satisfies a Spec satisfies its seam's most-specific design Spec; that `satisfies` edge is the component's own realization claim. As guidance, checked by no validator: a component either satisfies its seam's design Spec or binds no Spec its members satisfy, and it never references one. Structural edges confer nothing under the architectural-annotation ruling (MD-38), which supersedes structural anchors confer nothing (MD-30) without changing this.", + "structural anchor validity": + "A `memberOf` source is an `impl:` or `api:` CodeNode and its target is a `component:` CodeNode; every structural target exists, every edge is unique, each source has at most one component, and structural self-reference is refused. A malformed or non-static structural field, an unknown `layer`, or `layer` or `context` on a non-component anchor refuses the whole anchor at reification; a graph-validly reified edge that later fails referential or structural validation remains visible with its anchor and independent `satisfies` binding. Multi-node `uses` cycles remain data and produce no finding.", + "structural non-conferral": + "Structural edges, `role`, `layer`, and `context` carry no intent, delivery fact, readiness effect, or binding-to-Spec traversal; no `implements` field is admitted.", + "test anchor": + "A binding in the `test:` namespace that derives one anchored `verifies` edge per target from a test to each Spec it verifies.", + "oracle anchor": + "A binding in the `oracle:` namespace that records an oracle's `models` target without deriving a delivery fact.", + "executable binding boundary": + "A resolving `specTest` anchor can establish verifier realization; a `bindExample` call executes a generated contract but is not extracted graph data, so the graph cannot claim from that call alone that the contract is bound.", + "document-realization binding": + "When the realizing artifact is authored Markdown that cannot carry an extracted in-code anchor, the executable suite that asserts the shipped document may carry its code anchor. Its label must name the document realization rather than imply the test body is the product, and file-level blast radius remains coverage-unknown for the Markdown artifact.", + "Protocol builder binding": + "A builder import from the public Protocol package or its anchors subpath, or a relative import whose importer-relative resolution, including the TypeScript `.js`-to-`.ts` convention, canonicalizes to this package's `ids` or `model/code-anchor` module; consumer-local lookalike modules confer no binding authority. On the CommonJS package surface the trusted relative-module set is empty (`import.meta.url` is rewritten away), so relative bindings mint no anchors there while package imports stay trusted. The comment form needs no builder binding.", + "untrusted builder": + "A constant-form builder call whose import is no Protocol builder binding: it mints nothing and reports nothing, because a source file that never bound to the Protocol is not authoring drift to report. The realizing entrypoints are `protocolBindingScopeFor` and `collectProtocolBindings` in `src/extract/protocol-bindings.ts`.", + }, + }, behavior: { exampleSpace: { given: [ @@ -325,34 +370,6 @@ export const modelSpecs = [ ], }, }, - model: { - terms: { - "Protocol builder binding": - "A builder import from the public Protocol package, or a relative import whose importer-relative resolution — including the TypeScript `.js`-to-`.ts` convention — canonicalizes to this package's `ids` or `model/code-anchor` module; consumer-local lookalike modules confer no binding authority. On the CommonJS package surface the trusted relative-module set is empty (`import.meta.url` is rewritten away), so relative bindings mint no anchors there while package imports stay trusted.", - anchor: - "A human-written source binding from one code location to one Spec ID, carrying identity, an optional label, and one realization target only.", - "anchor-constant form": - "The top-level const builder call that the MVP extractor reifies; decorator and JSDoc forms remain unextracted representations.", - "code anchor": - "An implementation-flavored binding that derives an anchored satisfies edge. It may additionally name one `component?: ComponentAnchorId` and a non-empty, unique `uses?: readonly CodeAnchorId[]`; these closed graph-ID references derive only anchored CodeNode-to-CodeNode `memberOf` and `uses` edges.", - "component realization convention": - "A `component:` anchor satisfies its seam's most-specific design Spec; the `satisfies` edge is the component's own realization claim. Structural edges confer nothing, so MD-30 remains unchanged.", - "document-realization binding": - "When the realizing artifact is authored Markdown that cannot carry an extracted in-code anchor, the executable suite that asserts the shipped document may carry its code anchor. Its label must name the document realization rather than imply the test body is the product, and file-level blast radius remains coverage-unknown for the Markdown artifact.", - "executable binding boundary": - "A resolving `specTest` anchor can establish verifier realization; a `bindExample` call executes a generated contract but is not extracted graph data, so the graph cannot claim from that call alone that the contract is bound.", - "oracle anchor": - "A binding that records an oracle's models target without deriving a delivery fact.", - "structural anchor validity": - "A `memberOf` source is an `impl:` or `api:` CodeNode and its target is a `component:` CodeNode; every structural target exists, every edge is unique, each source has at most one component, and structural self-reference is refused. A malformed or non-static structural field refuses the whole anchor at reification; a graph-validly reified edge that later fails referential or structural validation remains visible with its anchor and independent `satisfies` binding. Multi-node `uses` cycles remain data and produce no finding.", - "structural non-conferral": - "Structural edges carry no intent, delivery fact, readiness effect, or binding-to-Spec traversal; no `implements` field is admitted.", - "test anchor": - "A binding that derives an anchored verifies edge from a test to its target Spec.", - "untrusted builder": - "A builder call whose import is no Protocol builder binding: it mints nothing and reports nothing, because a source file that never bound to the Protocol is not authoring drift to report. The realizing entrypoints are `protocolBindingScopeFor` and `collectProtocolBindings` in `src/extract/protocol-bindings.ts`.", - }, - }, }, deliveryFacts: ["implemented", "has-verifier"], }, @@ -422,17 +439,21 @@ export const modelSpecs = [ altitude: "feature", readiness: "defined", file: "specs/model/structural-patterns.sdp.md", - title: "Architectural significance dissolves into existing primitives", + title: "Architecturally significant units are annotated where they are realized", narrative: null, sections: { intent: { outcome: - "Express architectural responsibilities in Specs of the appropriate kind, connect their semantic dependencies with existing relations, and bind their implementations through structural anchors. Architectural significance adds no kind or vocabulary.", + "Annotate each architecturally significant unit on the anchor of the code that realizes it, with its role, its component's layer and bounded context, its uses, and the designs it references, so the graph answers architecture questions from the code itself. Architectural significance adds no Spec kind, no node type, and no vocabulary registry.", }, model: { terms: { "architecturally significant unit": - "a code unit with exported public surface or cross-component reach that warrants graph-visible structural binding.", + "A code unit with exported public surface or cross-component reach that warrants graph-visible structural binding: component membership, uses declarations for its architectural dependencies, a role, and `references` to the designs it answers to.", + "structural attributes": + "`role` on any code anchor; `layer` and `context` on a `component:` anchor; all three describe what the unit is, never where it stands, and the census renders their taxonomy from the graph.", + "accepted set": + "The set of architecturally significant units in a corpus is an owner-reviewed declaration, never derived from imports or exports; annotations are curated, never a coverage quota.", }, }, }, diff --git a/test/self-hosting-oracle/protocol.ts b/test/self-hosting-oracle/protocol.ts index 0b7f579e..1a18dbea 100644 --- a/test/self-hosting-oracle/protocol.ts +++ b/test/self-hosting-oracle/protocol.ts @@ -34,7 +34,7 @@ export const protocolSpecs = [ sections: { intent: { outcome: - "Every architecturally significant engine unit carries component membership and uses declarations so structural recipes and the census answer architecture questions about the engine itself.", + "Every architecturally significant engine unit carries component membership, uses declarations, and a role, every engine component declares its layer and context, and a unit references the decisions it answers to, so structural recipes and the census answer architecture questions about the engine itself.", }, behavior: { rules: [ @@ -42,6 +42,9 @@ export const protocolSpecs = [ "Every architecturally significant unit is covered at Spec-realization grain: it carries component membership through the anchor of the Spec it honestly realizes, or — for an implementation helper with no honest satisfies target of its own — through the nearest honest realization anchor that consumes it; it also carries uses declarations for each component it architecturally depends on, so structural recipes answer dependency questions about the engine itself.", "A component-level uses declaration tracks real imports, value or type, from another component's source files; imports that exist only to author the anchors themselves (the stable-id and anchor-builder modules) confer no edge.", "The accepted set of architecturally significant units is an owner-reviewed declaration recorded in the self-hosting oracle, never derived from imports or exports; the suite census-checks that every accepted unit carries its declared membership and that no unrostered membership edge exists.", + "Every engine `component:` anchor declares its `layer` and its `context`.", + "Every architecturally significant engine unit carries a `role`.", + "An engine unit references the decisions it follows, and the designs it answers to without wholly realizing them, through `references`; it satisfies only a Spec it wholly realizes, never a decision; a Spec Studio design that builds on engine code states that by its own `dependsOn` or `refines` to the Spec the code satisfies, never by a reference from the unit.", ], }, }, diff --git a/test/self-hosting-oracle/structural-edges.ts b/test/self-hosting-oracle/structural-edges.ts index 157f7df1..308c0db1 100644 --- a/test/self-hosting-oracle/structural-edges.ts +++ b/test/self-hosting-oracle/structural-edges.ts @@ -224,6 +224,11 @@ export const acceptedArchitecturalUnits = [ anchorId: "impl:protocol.graph-claims", componentId: "component:protocol.graph", }, + { + unit: "src/graph/schema.ts#graphEdgeTypes", + anchorId: "impl:protocol.graph-edge-types", + componentId: "component:protocol.graph", + }, { unit: "src/graph/delivery-facts.ts#computeDeliveryFacts", anchorId: "impl:protocol.delivery-facts", @@ -254,6 +259,11 @@ export const acceptedArchitecturalUnits = [ anchorId: "impl:protocol.anchor-model", componentId: "component:protocol.model", }, + { + unit: "src/anchors.ts#codeAnchor", + anchorId: "impl:protocol.anchors-subpath", + componentId: "component:protocol.model", + }, { unit: "src/model/descriptors.ts#SPEC_KIND_DISPLAY_LABELS", anchorId: "impl:protocol.spec-descriptors", @@ -461,6 +471,7 @@ export const acceptedArchitecturalUnits = [ }, ] as const; +// re-measured under plan 40 export const expectedMemberOfEdges = acceptedArchitecturalUnits.map((row) => [ row.anchorId, row.componentId, @@ -511,6 +522,7 @@ export const coarseGrainCoverage = [ // A uses edge tracks real imports (value or type) from another component's source files; imports // that exist only to author the anchors themselves (the stable-id and anchor-builder modules) // confer no edge. The convention is stated in spec:protocol.structural-self-binding. +// re-measured under plan 40 export const expectedUsesEdges = [ ["impl:protocol.example-runner-adapter", "impl:protocol.example-runner"], ["impl:protocol.authored-honesty-delivery-facts", "impl:protocol.delivery-facts"], diff --git a/test/skills.test.ts b/test/skills.test.ts index 923ce745..c8e907c0 100644 --- a/test/skills.test.ts +++ b/test/skills.test.ts @@ -178,7 +178,9 @@ describe("Protocol skill assets", () => { "spec:decisions.content-only-sections", "spec:decisions.point-per-example", "spec:decisions.binding-not-liveness", - "spec:decisions.structural-anchor-semantics", + "spec:decisions.architectural-annotation", + "spec:decisions.anchor-comment-form", + "spec:decisions.anchor-binding-grain", "spec:decisions.adopted-registrars-committed", "spec:extraction.runnable-modules", "sdp build", @@ -189,7 +191,11 @@ describe("Protocol skill assets", () => { "specOracle", "codeAnchor", "componentAnchorId", - "anchor-constant", + "constant form", + "comment form", + "@sdpAnchor", + "@libar-dev/software-delivery-protocol/anchors", + "recipe 28", ".test.generated.ts", "mints nothing and reports nothing", "document-realization", @@ -353,6 +359,8 @@ describe("Protocol skill assets", () => { "twenty-four", "twenty-five", "twenty-six", + "twenty-seven", + "twenty-eight", ] as const; const countWord = countWords[headings.length]; diff --git a/test/structural-anchors.test.ts b/test/structural-anchors.test.ts index bac6ed79..342a3ff2 100644 --- a/test/structural-anchors.test.ts +++ b/test/structural-anchors.test.ts @@ -332,3 +332,156 @@ export const subject = codeAnchor({ expect(result.graph.edges.some((edge) => edge.from === "impl:fixture.subject")).toBe(false); }); }); + +describe("the references edge (the binding grain)", () => { + function primitive(id: string): GraphNode { + return { + id, + nodeType: "Primitive", + claim: "declared", + specKind: "behavior", + altitude: "feature", + readiness: "idea", + file: "specs/target.sdp.md", + }; + } + + function structural(graph: GraphSchema) { + return validateGraph(graph).findings.filter( + (finding) => finding.validatorId === graphValidatorIds.structuralAnchors, + ); + } + + it("rejects a Spec named in both satisfies and references of one anchor", () => { + const source = codeNode("impl:fixture.subject"); + const target = primitive("spec:fixture.structural-target"); + const findings = structural( + syntheticGraph( + [source, target], + [ + { from: source.id, type: "satisfies", to: target.id, claim: "anchored" }, + { from: source.id, type: "references", to: target.id, claim: "anchored" }, + ], + ), + ); + + expect(findings).toEqual([ + expect.objectContaining({ + subjectId: "impl:fixture.subject", + relatedId: "spec:fixture.structural-target", + path: "references", + }), + ]); + expect(findings[0]?.message).toContain('both "satisfies" and "references"'); + }); + + it("requires a CodeNode source and a Spec target", () => { + const source: GraphNode = { + id: "test:fixture.verifier", + nodeType: "Anchor", + claim: "anchored", + file: "test/structural.test.ts", + line: 1, + }; + const component = codeNode("component:fixture.core"); + const graph = syntheticGraph( + [source, component], + [{ from: source.id, type: "references", to: component.id, claim: "anchored" }], + ); + + expect(structural(graph).map((finding) => finding.message)).toEqual([ + expect.stringContaining("references edge source must use an impl:, api:, or component:"), + expect.stringContaining("references edge must target a Spec id"), + ]); + expect( + validateGraph(graph).findings.filter( + (finding) => finding.validatorId === graphValidatorIds.claimSeparation, + ), + ).toHaveLength(2); + }); + + it("rejects the same references edge authored twice", () => { + const source = codeNode("impl:fixture.subject"); + const target = primitive("spec:fixture.structural-target"); + const findings = structural( + syntheticGraph( + [source, target], + [ + { from: source.id, type: "references", to: target.id, claim: "anchored" }, + { from: source.id, type: "references", to: target.id, claim: "anchored" }, + ], + ), + ); + + expect(findings).toHaveLength(1); + expect(findings[0]?.message).toContain("structural edges must be unique"); + }); + + it("reports a dangling references target through referential integrity and confers nothing", () => { + const result = extract({ + root: fixtureRoot(oneAnchor(' references: [ref("spec:fixture.missing")],')), + }); + + expect(result.report.findings).toEqual([]); + expect(validateGraph(result.graph).findings).toEqual([ + expect.objectContaining({ + validatorId: graphValidatorIds.referentialIntegrity, + severity: "error", + subjectId: "impl:fixture.subject", + relatedId: "spec:fixture.missing", + }), + ]); + expect(result.graph.edges).toContainEqual({ + from: "impl:fixture.subject", + type: "references", + to: "spec:fixture.missing", + claim: "anchored", + }); + expect(result.graph.nodes.find((node) => node.id === "spec:fixture.structural-target")).toEqual( + expect.objectContaining({ deliveryFacts: ["implemented"] }), + ); + }); + + it("refuses an empty references array at reification", () => { + const result = extract({ root: fixtureRoot(oneAnchor(" references: [],")) }); + + expect(result.report.findings).toEqual([ + expect.objectContaining({ + validatorId: graphValidatorIds.structuralAnchors, + severity: "error", + subjectId: "impl:fixture.subject", + path: "references", + }), + ]); + expect(result.graph.nodes.some((node) => node.id === "impl:fixture.subject")).toBe(false); + }); + + it("refuses a repeated references target and the satisfies overlap at reification", () => { + const repeated = extract({ + root: fixtureRoot( + oneAnchor(' references: [ref("spec:fixture.other"), ref("spec:fixture.other")],'), + ), + }); + const overlap = extract({ + root: fixtureRoot(oneAnchor(' references: [ref("spec:fixture.structural-target")],')), + }); + + expect(repeated.report.findings).toEqual([ + expect.objectContaining({ + validatorId: graphValidatorIds.structuralAnchors, + subjectId: "impl:fixture.subject", + relatedId: "spec:fixture.other", + path: "references", + }), + ]); + expect(overlap.report.findings).toEqual([ + expect.objectContaining({ + validatorId: graphValidatorIds.structuralAnchors, + subjectId: "impl:fixture.subject", + relatedId: "spec:fixture.structural-target", + path: "references", + }), + ]); + expect(overlap.graph.nodes.some((node) => node.id === "impl:fixture.subject")).toBe(false); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 077a2bfd..95ed444d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,8 @@ "types": ["node"], "noEmit": true, "paths": { - "@libar-dev/software-delivery-protocol": ["./src/index.ts"] + "@libar-dev/software-delivery-protocol": ["./src/index.ts"], + "@libar-dev/software-delivery-protocol/anchors": ["./src/anchors.ts"] } }, "include": ["src/**/*.ts", "test/**/*.ts", "tsup.config.ts", "vitest.config.ts"], diff --git a/tsup.config.ts b/tsup.config.ts index 9b98a4cb..68aa6f79 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -7,6 +7,7 @@ export default defineConfig([ { entry: [ "src/index.ts", + "src/anchors.ts", "src/cli/sdp.ts", "src/runner/index.ts", "src/adapters/vitest.ts", diff --git a/vitest.config.ts b/vitest.config.ts index f6611755..936a6e5a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,7 @@ import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; const packageAliasTarget = fileURLToPath(new URL("./src/index.ts", import.meta.url)); +const anchorsAliasTarget = fileURLToPath(new URL("./src/anchors.ts", import.meta.url)); const runnerAliasTarget = fileURLToPath(new URL("./src/runner/index.ts", import.meta.url)); const vitestAdapterAliasTarget = fileURLToPath( new URL("./src/adapters/vitest.ts", import.meta.url), @@ -13,6 +14,7 @@ export default defineConfig({ // Array form: subpath entries must match before the bare specifier (a prefix alias would // otherwise rewrite ".../runner" into a path inside index.ts). alias: [ + { find: "@libar-dev/software-delivery-protocol/anchors", replacement: anchorsAliasTarget }, { find: "@libar-dev/software-delivery-protocol/runner", replacement: runnerAliasTarget }, { find: "@libar-dev/software-delivery-protocol/vitest",