Skip to content

Commit a8f08fb

Browse files
dmealingclaude
andcommitted
feat(codegen-ts): the requirements doc projection — task 1 of the requirements surface
Plan: docs/superpowers/plans/2026-08-21-requirements-doc-surface.md. Adds `requirementRows()`, the render-ready projection the `requirements` docs surface will bind to. No surface wiring yet, no generator, no emitted file — this is the data tier alone, and nothing calls it. PROJECTS OVER THE EXISTING WALK RATHER THAN ADDING ONE. The plan originally specified a new walk; recon found `requirement-walk.ts` already does the depth-first traversal including nested nodes, already builds the dotted `path`, and already resolves @implementedBy targets — it is what `requirementTests()` is built on. Reusing it makes the doc view and the generated stubs agree about the ledger BY CONSTRUCTION rather than by two walks somebody has to keep in step. The plan is corrected rather than followed. `notes` IS ABSENT FROM THE TYPE, not merely unread. documentation.json charters it internal-only and never emitted to a user-facing surface; the original "annotate then strip for deployment" ask this design replaced wanted a stripper for exactly this. A renderer cannot leak a field the projection does not carry, which is stronger than every renderer remembering to omit it. TWO CORRECTIONS TO THE DESIGN, both from reading the code rather than the ADR: - §7 said the structural test link waits on `requirementTests()` "targeting 1.1". The generator ALREADY SHIPS in TypeScript (generators/requirement-tests.ts, exported from src/index.ts:204); the 1.1 note scopes the retirement plan's work, not the generator's existence. Writing "not yet built" about shipped code is the same stale-claim class this session spent its first half fixing in the agent context. - The blocker is therefore NOT computing the stub path — `requirements/<path>.<concern> .test.ts` derives from metadata alone. It is that WHETHER A STUB EXISTS depends on the project having wired the generator, which is not in the registry and must be added by hand. Emitting a path for a project that never wired it would assert a file that does not exist — the scan's false-assurance failure relocated, not avoided. So the link stays gated. Both challenge arms circled this tension; neither settled it. THE `notes` TEST WAS VACUOUS WHEN FIRST WRITTEN and is only a gate because that was caught. The fixture declared `description`/`notes` BARE; canonical JSON prefixes inline attrs with `@`, so neither attr ever loaded and the sentinel assertion passed against a node that carried nothing. Fixed to `@description`/`@notes`, then PROVEN able to fail: adding a `leaked: stringAttr(node, "notes")` field to the projection turns it red, and removing it turns it green. A gate never demonstrated failing is decorative. The fixture is shaped around the two silent failure modes: three-level NESTING (a flat ledger cannot tell a depth-first walk from a top-level-only one — both return the same rows) and a requirement carrying BOTH description and notes (with only one, a test cannot tell suppression from absence). Verified: codegen-ts 1271 pass / 0 fail, typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent dbe156e commit a8f08fb

4 files changed

Lines changed: 449 additions & 9 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# The `requirements` docs surface — implementation plan
2+
3+
Design: [`2026-08-21-requirements-doc-surface-design.md`](../specs/2026-08-21-requirements-doc-surface-design.md).
4+
5+
**Slice scope:** the surface only — the requirement walk, both renderers, the generator, shape
6+
C's backlink, and the surface wiring. Additive. **Must not change `meta docs` output for any
7+
model that declares no `requirement.*` node.**
8+
9+
## Global constraints
10+
11+
1. **Metadata alone.** `meta docs` guarantees output *"from metadata **ALONE** — no gen config,
12+
no codegen pipeline"* ([`docs.ts:1-12`](../../../server/typescript/packages/cli/src/commands/docs.ts)).
13+
This surface adds **no** new input. No filesystem walk, no test-source scan, no config read.
14+
That sentence is what rules out the retired `@verifiedBy` scan (design §3) and it must keep
15+
being true.
16+
2. **No test link, under any input.** Design §3. There is no join key; do not invent one.
17+
3. **Resolving accessors only.** ADR-0039 — a requirement's `@statement`, `@level`, `@status`
18+
and `@implementedBy` must be read through the resolving accessors on `MetaRequirement`
19+
(`level()`, `status()`, `implementedBy()`, `trackedBy()`, `disposition()`), never `own*()`.
20+
A requirement that `extends` an abstract parent must inherit.
21+
4. **`notes` is never emitted.** [`documentation.json:39`](../../../spec/metamodel/documentation.json)
22+
internal-only. `description` is emitted.
23+
5. **Constants, not literals.** `"requirements"` (the surface), the two filenames, and every
24+
metamodel string come from named constants.
25+
26+
## Task 1 — project the docs row from the EXISTING walk
27+
28+
**Do not write a new walk.**
29+
[`codegen-ts/src/requirement-walk.ts`](../../../server/typescript/packages/codegen-ts/src/requirement-walk.ts)
30+
already does the depth-first walk of every `requirement.*` node — nested ones included — and
31+
already produces the dotted `path` (hierarchy is nesting), `subType`, `level`, `status`, and
32+
resolved `@implementedBy` targets as `ResolvedClaim[]`. It is what `requirementTests()` is
33+
built on, so reusing it keeps the docs surface and the stub generator agreeing on what the
34+
ledger contains **by construction** rather than by two walks that must be kept in step.
35+
36+
**New:** `codegen-ts/src/generators/requirements-view.ts` — a projection over
37+
`walkRequirements(root)` adding only what the docs surface needs and `RequirementView` lacks:
38+
39+
```ts
40+
export interface RequirementRow {
41+
readonly path: string; // dotted child-name path, e.g. "checkout.payment.capture"
42+
readonly depth: number; // 0 for a root-level node
43+
readonly subType: string; // "functional" | "architectural"
44+
readonly level: number | undefined;
45+
readonly status: string | undefined;
46+
readonly disposition: string | undefined;
47+
readonly trackedBy: readonly string[];
48+
readonly statement: string | undefined;
49+
readonly violation: string | undefined;
50+
readonly description: string | undefined; // NEVER notes
51+
readonly implementedBy: readonly string[];
52+
}
53+
export function requirementRows(root: MetaData): RequirementRow[];
54+
```
55+
56+
Depth-first, declaration order. `path` is the dotted child-name path, matching how every other
57+
node in the model is addressed.
58+
59+
**Tests first:**
60+
- a flat ledger projects one row per node, in declaration order
61+
- **a nested ledger projects parent AND children, with correct `depth` and dotted `path`** — a
62+
flat fixture cannot tell a depth-first walk from a top-level-only one, which is the specific
63+
way this walk fails silently
64+
- a requirement that `extends` an abstract parent inherits `@level`/`@status` (ADR-0039 —
65+
assert against a fixture where the child declares neither)
66+
- `notes` is absent from `RequirementRow` at the type level and from every projected value
67+
- a root with zero requirement nodes returns `[]`
68+
69+
## Task 2 — the markdown renderer
70+
71+
**New:** `codegen-ts/src/generators/requirements-markdown.ts`, `renderRequirementsMarkdown(rows)`.
72+
73+
Nesting rendered as heading depth; each row carries statement, violation, level, status,
74+
disposition, trackedBy and resolved `@implementedBy` targets. `description` emitted where
75+
present.
76+
77+
**Tests first:** a golden over a nested fixture; `notes` absent; an empty row list renders
78+
nothing (not an empty document — Task 6 depends on this).
79+
80+
## Task 3 — the TOON renderer
81+
82+
**New:** `codegen-ts/src/generators/requirements-toon.ts`, `renderRequirementsToon(rows)`.
83+
84+
Reuses `toonEncode` from the CLI's existing `lib/format.ts` convention and the already-present
85+
`@toon-format/toon` dependency — **no new dependency**. The header must declare the row count:
86+
`requirements[N]{path,level,status,claims,statement}:`.
87+
88+
**Tests first:**
89+
- **the declared header count equals the number of rows emitted, on a NESTED fixture** — the
90+
gate that makes the artifact self-checking, and the reason TOON was chosen at all (design §5)
91+
- prose containing a comma round-trips (TOON quotes comma-bearing strings; assert rather than
92+
assume)
93+
- empty row list emits nothing
94+
95+
## Task 4 — the `requirementsFile()` generator
96+
97+
**New:** `codegen-ts/src/generators/requirements-file.ts`.
98+
99+
`oncePerRun`, emitting `requirements.md` + `requirements.toon`. Both unconditionally when the
100+
ledger is non-empty; **neither when it is empty** (design §6 rule 2). Registered in
101+
`generator-registry.ts` and exported from `generators/index.ts`.
102+
103+
**Tests first:** both files emitted for a ledger; **zero files for an empty ledger**; emission
104+
is independent of any config being loadable.
105+
106+
## Task 5 — shape C, the backlink on entity pages
107+
108+
Extend `docs-file.ts` so an entity page names the requirements claiming it, resolved from the
109+
Task 1 rows by `@implementedBy`.
110+
111+
**Tests first:**
112+
- a claimed entity's page gains the backlink
113+
- **an unclaimed entity's page is byte-identical to today's** — the no-churn pin
114+
- an entity claimed by several requirements lists all of them
115+
- entity-grain only: a claimed `object.value` / `object.projection` gains nothing
116+
([`capability-ledger.md:285-296`](../../../spec/capability-ledger.md) — coverage is
117+
entity-grain)
118+
119+
## Task 6 — surface wiring
120+
121+
- `DocsSurface` gains `"requirements"`
122+
([`metaobjects-config.ts:195`](../../../server/typescript/packages/codegen-ts/src/metaobjects-config.ts)).
123+
- `resolveDocsConfig`'s default becomes `["model", "api", "requirements"]`. Safe **only**
124+
because Task 4 emits nothing for an empty ledger — do not land this before Task 4 is green.
125+
- `docs.ts` gains a `--requirements` flag narrowing markdown surfaces exactly as
126+
`--model` / `--api` do, and a dispatch branch. `--site` / `--metamodel` are untouched (not
127+
surfaces).
128+
129+
**Tests first:** `--requirements` alone emits only this surface; no flag emits all three; a
130+
project with no ledger sees byte-identical output to before this plan.
131+
132+
## Task 7 — the conformance fixture
133+
134+
A `codegen-conformance` case whose model carries a nested `requirement.*` tree, gating both
135+
artifacts byte-for-byte — the mechanism that keeps the markdown and the TOON in agreement.
136+
137+
**The fixture must carry, or the gate is blind:**
138+
- **nesting** (Tasks 1 + 3 both fail silently on a flat corpus)
139+
- a requirement declaring **both `description` and `notes`** — with only one of them, a test
140+
cannot tell suppression from absence
141+
- a requirement whose `name` **exactly matches a real test file in this repo** — the case that
142+
would regress design §3 silently if anyone reintroduces a scan
143+
- an entity claimed by several requirements, and one claimed by none
144+
- a child requirement inheriting `@level`/`@status` through `extends`
145+
146+
**Prove the gate can fail:** sabotage the fixture and watch the lane go red. A gate never
147+
demonstrated failing is decorative — this repo has been bitten precisely here, where a golden
148+
went quiet the moment it was regenerated to match a bad fix.
149+
150+
## Deferred — explicitly NOT this slice
151+
152+
- **The structural test link** (design §7). `requirementTests()` already ships, and its default
153+
stub path is metadata-derivable — so the blocker is *not* computing the path. It is that
154+
**whether a stub exists** depends on the project having wired the generator, which is a
155+
gen-config fact this command cannot see. Emitting a path for a project that never wired it
156+
would assert a file that does not exist — the scan's false-assurance failure, relocated. So
157+
the link lands as config-gated enrichment on the `api`-surface precedent
158+
([`docs.ts:412`](../../../server/typescript/packages/cli/src/commands/docs.ts)), in its own
159+
slice.
160+
- **The spec-citation slot** (design §8) — a metamodel question for ADR-0037, not a docs one.
161+
- **Non-TS ports.** `meta docs` is Node-CLI-owned.
162+
- **`docs.layout` interaction** for the index page (design §11).
163+
164+
## Self-review
165+
166+
- Every task's tests are named before its implementation, per this repo's TDD discipline.
167+
- The two ways this feature fails *silently* — dropping rows from a nested walk, and emitting
168+
`notes` — each get a fixture shaped so the failure cannot pass.
169+
- The default-on change (Task 6) is sequenced strictly after the empty-ledger guard (Task 4),
170+
because in the other order every existing project's `meta docs` gains a file.

docs/superpowers/specs/2026-08-21-requirements-doc-surface-design.md

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -192,17 +192,34 @@ nothing there changes.
192192
`["model", "api", "requirements"]`. Rule 2 is what makes that a no-op for every project
193193
without a ledger.
194194

195-
## 7. The test link returns structurally, later
196-
197-
When `requirementTests()` ships (FR-038 slice 1,
198-
[plan](../plans/2026-08-16-fr-038-requirement-test-codegen.md), targeting 1.1), it emits
199-
`tests/requirements/<requirement dotted path>.<concern>.test.ts`. The link then becomes a
195+
## 7. The test link returns structurally — and the generator already ships
196+
197+
**Correction to an earlier reading of this, recorded because it changes the sequencing.**
198+
FR-038 slice 1 is **not** pending: `requirementTests()` is implemented and exported in
199+
TypeScript today
200+
([`generators/requirement-tests.ts`](../../../server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts),
201+
exported from `src/index.ts:204`), built on
202+
[`requirement-walk.ts`](../../../server/typescript/packages/codegen-ts/src/requirement-walk.ts).
203+
The "targeting 1.1" note in the coordinated batch plan scopes *that* plan's work, not the
204+
generator's existence.
205+
206+
It emits `requirements/<requirement dotted path>.<concern>.test.ts`. The link is therefore a
200207
**derived path, not an inference** — no scanning, nothing matched, nothing guessed.
201208

202-
**One tension must be resolved before that lands, and it is not resolved here.** The stub's
203-
location is an app seam (design §6, `path`) and §13 leaves "where do stubs live" an open
204-
default. So the stub path lives in **gen config** — which rule 1 above says this command does
205-
not read.
209+
**And the path is metadata-alone-derivable**, since `RequirementView.path` and the concern key
210+
both come from `walkRequirements()`. So rule 1 does *not* block computing it.
211+
212+
**The tension is sharper than "can we compute the path", and it is the reason this still
213+
waits.** `requirementTests()` is **not** in `generator-registry.ts` — a project must wire it
214+
explicitly in `metaobjects.config.ts` (deliberately, per §10's opt-in). So:
215+
216+
- **the path** is a metadata fact — computable here;
217+
- **whether a stub exists at it** is a gen-config fact — *not* computable here.
218+
219+
Rendering a derived path for a project that never wired the generator would assert a file that
220+
does not exist. **That is the same false-assurance failure the scan was rejected for**, relocated
221+
— a document telling a reader a requirement is tested when nothing tests it. So the link stays
222+
gated on knowing the generator ran.
206223

207224
The precedent for that already exists in the same file: the `api` surface materialises only
208225
with a loadable gen config, *because* api docs describe a generated surface that only exists
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// server/typescript/packages/codegen-ts/src/generators/requirements-view.ts
2+
//
3+
// The projected row the `requirements` docs surface renders from.
4+
//
5+
// Design: docs/superpowers/specs/2026-08-21-requirements-doc-surface-design.md
6+
//
7+
// PROJECTS OVER `walkRequirements()`, NEVER RE-WALKS. That walk already resolves the
8+
// depth-first traversal, the dotted `path` (hierarchy IS nesting), and the
9+
// `@implementedBy` targets, and it is what `requirementTests()` is built on. Sharing it
10+
// means the doc view and the generated stubs agree about what the ledger contains by
11+
// construction; two walks kept in step by hand is exactly the drift this surface exists
12+
// to remove.
13+
//
14+
// `notes` IS DELIBERATELY ABSENT FROM THIS TYPE, not merely unread. `documentation.json`
15+
// charters it internal-only, never emitted to a user-facing doc surface, and the
16+
// original ask this design replaced wanted a "stripper" for precisely this. A renderer
17+
// cannot leak a field the projection never carries — which is a stronger guarantee than
18+
// asking every renderer to remember.
19+
//
20+
// RESOLVING ACCESSORS ONLY (ADR-0039). Every read below goes through MetaRequirement's
21+
// resolving accessors or the resolving `attr()`, so a requirement that `extends` an
22+
// abstract parent inherits its `@level` / `@status` / statement rather than projecting
23+
// them as undefined. No `own*()` call belongs in this file.
24+
25+
import {
26+
DOC_ATTR_DESCRIPTION,
27+
REQUIREMENT_ATTR_STATEMENT,
28+
REQUIREMENT_ATTR_VIOLATION,
29+
} from "@metaobjectsdev/metadata";
30+
import type { MetaData } from "@metaobjectsdev/metadata";
31+
import { walkRequirements } from "../requirement-walk.js";
32+
33+
/** One requirement, projected for rendering. Ordered depth-first, declaration order. */
34+
export interface RequirementRow {
35+
/** Dotted child-name path from the root — the same address every other node uses. */
36+
readonly path: string;
37+
/** 0 for a root-level requirement. Derived from `path`, so it cannot disagree with it. */
38+
readonly depth: number;
39+
/** "functional" | "architectural" — the check-polarity axis. */
40+
readonly subType: string;
41+
/** Undefined on an unlevelled architectural requirement (the flat policy form). */
42+
readonly level: number | undefined;
43+
readonly status: string | undefined;
44+
readonly disposition: string | undefined;
45+
readonly trackedBy: readonly string[];
46+
readonly statement: string | undefined;
47+
readonly violation: string | undefined;
48+
/** Chartered user-facing. `notes` has no counterpart here, by design. */
49+
readonly description: string | undefined;
50+
/** `@implementedBy` exactly as authored — provenance a reader can grep for. */
51+
readonly implementedBy: readonly string[];
52+
/** DISTINCT `type.subType` concerns among the targets that actually resolved. */
53+
readonly claimedConcerns: readonly string[];
54+
}
55+
56+
/** Reads a resolving string attr, normalising "declared but empty" to undefined so a
57+
* renderer has one absent-case to handle rather than two. */
58+
function stringAttr(node: MetaData, name: string): string | undefined {
59+
// `attr()` is the RESOLVING accessor in TypeScript (ADR-0039). Note the cross-port
60+
// naming inversion this repo records: Python's `attr()` is own-only, TS's resolves.
61+
const raw = node.attr(name);
62+
if (typeof raw !== "string") return undefined;
63+
return raw.length > 0 ? raw : undefined;
64+
}
65+
66+
/**
67+
* Project every `requirement.*` node — nested ones included — into render-ready rows.
68+
*
69+
* Returns `[]` for a model declaring no requirements, which is what lets the surface
70+
* emit NOTHING rather than an empty page, and is in turn what makes turning the surface
71+
* on by default a no-op for every project without a ledger.
72+
*/
73+
export function requirementRows(root: MetaData): RequirementRow[] {
74+
return walkRequirements(root).map((walked) => {
75+
const { node, view, targets } = walked;
76+
return {
77+
path: view.path,
78+
// Derived rather than threaded: a separately-tracked depth could drift from the
79+
// path that renders beside it, and there is only one right answer.
80+
depth: view.path.split(".").length - 1,
81+
subType: view.subType,
82+
level: view.level,
83+
status: view.status,
84+
disposition: node.disposition(),
85+
trackedBy: node.trackedBy(),
86+
statement: stringAttr(node, REQUIREMENT_ATTR_STATEMENT),
87+
violation: stringAttr(node, REQUIREMENT_ATTR_VIOLATION),
88+
description: stringAttr(node, DOC_ATTR_DESCRIPTION),
89+
implementedBy: node.implementedBy(),
90+
claimedConcerns: [...new Set(targets.map((t) => t.concern))],
91+
};
92+
});
93+
}

0 commit comments

Comments
 (0)